src/Products/NotificationsBundle/Subscriber/OneRoster/OneRosterPrepareSubscriber.php line 98

Open in your IDE?
  1. <?php
  2. namespace Products\NotificationsBundle\Subscriber\OneRoster;
  3. use App\Service\Data\PhoneNumberService;
  4. use App\Service\Vendors\Twilio\TwilioFactory;
  5. use Cms\CoreBundle\Doctrine\Hydrators\SingleColumnHydrator;
  6. use Cms\CoreBundle\Entity\AbstractOneRosterEntity;
  7. use Cms\CoreBundle\Entity\OneRoster\OneRosterUser;
  8. use Cms\CoreBundle\Entity\OneRosterJob;
  9. use Cms\CoreBundle\Entity\OneRosterSync;
  10. use Cms\CoreBundle\Events\OneRosterAdhocEvent;
  11. use Cms\CoreBundle\Events\OneRosterEvents;
  12. use Cms\CoreBundle\Service\OneRosterService;
  13. use Cms\CoreBundle\Util\DateTimeUtils;
  14. use Cms\CoreBundle\Util\Doctrine\EntityManager;
  15. use PDO;
  16. use Platform\QueueBundle\Event\AsyncEvent;
  17. use Platform\QueueBundle\Model\AsyncMessage;
  18. use Platform\QueueBundle\Service\AsyncQueueService;
  19. use Products\NotificationsBundle\Entity\AbstractRecipient;
  20. use Products\NotificationsBundle\Entity\Profile;
  21. use Products\NotificationsBundle\Entity\ProfileContact;
  22. use Products\NotificationsBundle\Entity\ProfileRelationship;
  23. use Products\NotificationsBundle\Entity\Recipients\EmailRecipient;
  24. use Products\NotificationsBundle\Entity\Recipients\PhoneRecipient;
  25. use Products\NotificationsBundle\Entity\Student;
  26. use Products\NotificationsBundle\Util\Reachability;
  27. use Symfony\Contracts\Translation\TranslatorInterface;
  28. /**
  29.  * Class OneRosterPrepareSubscriber
  30.  * @package Products\NotificationsBundle\Subscriber\OneRoster
  31.  */
  32. final class OneRosterPrepareSubscriber extends AbstractNotificationsOneRosterSubscriber
  33. {
  34.     const METADATA__PHONES = [
  35.         'gg4l.home_phone' => AbstractRecipient::KINDS__VOICE,
  36.         'gg4l.work_phone' => AbstractRecipient::KINDS__VOICE,
  37.     ];
  38.     const METADATA__EXTRA_PHONES '_phones_extra';
  39.     const METADATA__EXTRA_EMAILS '_emails_extra';
  40.     const ADHOC_EVENTS__CONTACT_PREPARATION OneRosterEvents::EVENT__PREPARE '.adhoc.notifications.contact_preparation';
  41.     // DI
  42.     protected AsyncQueueService $async;
  43.     protected PhoneNumberService $phones;
  44.     protected TwilioFactory $twilio;
  45.     /**
  46.      * {@inheritdoc}
  47.      */
  48.     public static function getSubscribedEvents(): array
  49.     {
  50.         return [
  51.             OneRosterEvents::EVENT__PREPARE => [
  52.                 ['discardProfiles'0],
  53.                 ['discardStudents'0],
  54.                 // TODO: just remove this stuff?
  55.                 //['disassociateContacts', 0],
  56.                 // TODO: do we need to have these things using the discardable stuff?
  57.                 ['disassociateRelations'0],
  58.                 ['contactsPrep'0],
  59.             ],
  60.             self::ADHOC_EVENTS__CONTACT_PREPARATION => [
  61.                 ['contactInjection'0],
  62.                 ['contactLookup'0],
  63.             ],
  64.         ];
  65.     }
  66.     /**
  67.      * {@inheritDoc}
  68.      * @param AsyncQueueService $async
  69.      * @param PhoneNumberService $phones
  70.      * @param TwilioFactory $twilio
  71.      */
  72.     public function __construct(
  73.         EntityManager $em,
  74.         OneRosterService $oneroster,
  75.         TranslatorInterface $translator,
  76.         AsyncQueueService $async,
  77.         PhoneNumberService $phones,
  78.         TwilioFactory $twilio
  79.     )
  80.     {
  81.         parent::__construct($em$oneroster$translator);
  82.         $this->async $async;
  83.         $this->phones $phones;
  84.         $this->twilio $twilio;
  85.     }
  86.     /**
  87.      * @param AsyncEvent $event
  88.      * @return void
  89.      */
  90.     public function discardProfiles(AsyncEvent $event): void
  91.     {
  92.         // data should be an array with an id of a sync
  93.         $job $this->loadJob($event);
  94.         // run bulk query
  95.         // this should discard every profile that has an invalid/missing oneroster id
  96.         $discards $this->em->createQueryBuilder()
  97.             ->update(Profile::class, 'profiles')
  98.             ->set('profiles.discarded'':state')
  99.             ->setParameter('state'true)
  100.             ->set('profiles.discardedAt'':timestamp')
  101.             ->setParameter('timestamp'DateTimeUtils::now())
  102.             ->andWhere('profiles.tenant = :tenant')// filter by tenant
  103.             ->setParameter('tenant'$job->getTenant()->getId())
  104.             ->andWhere('profiles.discarded != :state')// for performance, only update ones not already discarded
  105.             ->andWhere($this->em->getExpressionBuilder()->notIn(
  106.                 'profiles.onerosterId',
  107.                 $this->em->createQueryBuilder()
  108.                     ->select('objects.sourcedId')
  109.                     ->from(AbstractOneRosterEntity::class, 'objects')
  110.                     ->andWhere('objects.tenant = :tenant')// we are making a string subquery, so the tenant var in the other qb will be used here eventually
  111.                     ->andWhere('objects.status = :status')// filter out things that are marked as to be deleted
  112.                     ->getDQL()
  113.             ))// only match objects that are missing from stashed objects
  114.             ->setParameter('status'AbstractOneRosterEntity::ENUMS__STATUS_TYPE__ACTIVE)// have to set the parameter here for the subquery
  115.             ->getQuery()
  116.             ->execute();
  117.         // DEBUGGING
  118.         $event->getOutput()->writeln(sprintf(
  119.             'Discarded %s profiles for sync #%s',
  120.             $discards,
  121.             $job->getIdentifier()
  122.         ));
  123.     }
  124.     /**
  125.      * @param AsyncEvent $event
  126.      * @return void
  127.      */
  128.     public function discardStudents(AsyncEvent $event): void
  129.     {
  130.         // data should be an array with an id of a sync
  131.         $job $this->loadJob($event);
  132.         // run bulk query
  133.         // this should discard every profile that has an invalid/missing oneroster id
  134.         $discards $this->em->createQueryBuilder()
  135.             ->update(Student::class, 'students')
  136.             ->set('students.discarded'':state')
  137.             ->setParameter('state'true)
  138.             ->set('students.discardedAt'':timestamp')
  139.             ->setParameter('timestamp'DateTimeUtils::now())
  140.             ->andWhere('students.tenant = :tenant')// filter by tenant
  141.             ->setParameter('tenant'$job->getTenant()->getId())
  142.             ->andWhere('students.discarded != :state')// for performance, only update ones not already discarded
  143.             ->andWhere($this->em->getExpressionBuilder()->notIn(
  144.                 'students.onerosterId',
  145.                 $this->em->createQueryBuilder()
  146.                     ->select('objects.sourcedId')
  147.                     ->from(AbstractOneRosterEntity::class, 'objects')
  148.                     ->andWhere('objects.tenant = :tenant')// we are making a string subquery, so the tenant var in the other qb will be used here eventually
  149.                     ->andWhere('objects.status = :status')// filter out things that are marked as to be deleted
  150.                     ->getDQL()
  151.             ))// only match objects that are missing from stashed objects
  152.             ->setParameter('status'AbstractOneRosterEntity::ENUMS__STATUS_TYPE__ACTIVE)// have to set the parameter here for the subquery
  153.             ->getQuery()
  154.             ->execute();
  155.         // DEBUGGING
  156.         $event->getOutput()->writeln(sprintf(
  157.             'Discarded %s students for sync #%s',
  158.             $discards,
  159.             $job->getIdentifier()
  160.         ));
  161.     }
  162.     /**
  163.      * @param AsyncEvent $event
  164.      * @return void
  165.      */
  166.     public function disassociateContacts(AsyncEvent $event): void
  167.     {
  168.         // data should be an array with an id of a sync
  169.         $job $this->loadJob($event);
  170.         // run bulk query
  171.         // any profile contact tied to a profile that has been discarded should be removed
  172.         $removals $this->em->createQueryBuilder()
  173.             ->delete(ProfileContact::class, 'contacts')
  174.             ->andWhere('contacts.tenant = :tenant')// filter by tenant
  175.             ->setParameter('tenant'$job->getTenant()->getId())
  176.             ->andWhere($this->em->getExpressionBuilder()->in(
  177.                 'contacts.profile',
  178.                 $this->em->createQueryBuilder()
  179.                     ->select('profiles.id')
  180.                     ->from(Profile::class, 'profiles')
  181.                     ->andWhere('profiles.tenant = :tenant')// we are making a string subquery, so the tenant var in the other qb will be used here eventually
  182.                     ->andWhere('profiles.discarded = :discarded')
  183.                     ->getDQL()
  184.             ))// only match things that are tied to discarded profiles
  185.             ->setParameter('discarded'true)// setting parameter here because doing a subquery
  186.             ->getQuery()
  187.             ->execute();
  188.         // DEBUGGING
  189.         $event->getOutput()->writeln(sprintf(
  190.             'Disassociated %s profile contacts for sync #%s',
  191.             $removals,
  192.             $job->getIdentifier()
  193.         ));
  194.     }
  195.     /**
  196.      * @param AsyncEvent $event
  197.      * @return void
  198.      */
  199.     public function disassociateRelations(AsyncEvent $event): void
  200.     {
  201.         // data should be an array with an id of a sync
  202.         $job $this->loadJob($event);
  203.         // run bulk query
  204.         // any profile contact tied to a profile that has been discarded should be removed
  205.         $removals $this->em->createQueryBuilder()
  206.             ->delete(ProfileRelationship::class, 'relationships')
  207.             ->andWhere('relationships.tenant = :tenant')// filter by tenant
  208.             ->setParameter('tenant'$job->getTenant()->getId())
  209.             ->andWhere($this->em->getExpressionBuilder()->orX(
  210.                 $this->em->getExpressionBuilder()->in(
  211.                     'relationships.profile',
  212.                     $this->em->createQueryBuilder()
  213.                         ->select('profiles.id')
  214.                         ->from(Profile::class, 'profiles')
  215.                         ->andWhere('profiles.tenant = :tenant')// we are making a string subquery, so the tenant var in the other qb will be used here eventually
  216.                         ->andWhere('profiles.discarded = :discarded')
  217.                         ->getDQL()
  218.                 ),
  219.                 $this->em->getExpressionBuilder()->in(
  220.                     'relationships.student',
  221.                     $this->em->createQueryBuilder()
  222.                         ->select('students.id')
  223.                         ->from(Student::class, 'students')
  224.                         ->andWhere('students.tenant = :tenant')// we are making a string subquery, so the tenant var in the other qb will be used here eventually
  225.                         ->andWhere('students.discarded = :discarded')
  226.                         ->getDQL()
  227.                 )
  228.             ))// handle either the student being discarded or the profile being discarded
  229.             ->setParameter('discarded'true)// setting parameter here because doing a subquery
  230.             ->getQuery()
  231.             ->execute();
  232.         // DEBUGGING
  233.         $event->getOutput()->writeln(sprintf(
  234.             'Disassociated %s profile relationships for sync #%s',
  235.             $removals,
  236.             $job->getIdentifier()
  237.         ));
  238.     }
  239.     /**
  240.      * @param AsyncEvent $event
  241.      * @return void
  242.      */
  243.     public function contactsPrep(AsyncEvent $event): void
  244.     {
  245.         // data should be an array with an id of a sync
  246.         $job $this->loadJob($event);
  247.         // determine what roles we are interested in
  248.         $roles = [];
  249.         if ($job->getSync()->hasStrategy(OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF)) {
  250.             $roles array_merge($roles, [
  251.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__ADMINISTRATOR,
  252.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__AIDE,
  253.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__PROCTOR,
  254.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__TEACHER,
  255.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__STAFF,
  256.             ]);
  257.         }
  258.         if ($job->getSync()->hasStrategy(OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY)) {
  259.             $roles array_merge($roles, [
  260.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__GUARDIAN,
  261.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__PARENT,
  262.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__RELATIVE,
  263.             ]);
  264.         }
  265.         if ($job->getSync()->hasStrategy(OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS)) {
  266.             $roles array_merge($roles, [
  267.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__STUDENT,
  268.             ]);
  269.         }
  270.         if ($job->getSync()->hasStrategy(OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY)) {
  271.             $roles array_merge($roles, [
  272.                 AbstractOneRosterEntity::ENUMS__ROLE_TYPE__COMMUNITY,
  273.             ]);
  274.         }
  275.         $roles array_unique($roles);
  276.         // get all the unique emails from the stashed data
  277.         $contacts array_fill_keys(
  278.             array_values(array_unique(array_filter(array_map(
  279.                 function (?string $contact) {
  280.                     return strtolower(trim($contact));
  281.                 },
  282.                 $this->em->createQueryBuilder()
  283.                     ->select('DISTINCT users.email')
  284.                     ->from(OneRosterUser::class, 'users')
  285.                     ->andWhere('users.sync = :sync')
  286.                     ->setParameter('sync'$job->getSync())
  287.                     ->andWhere('users.role IN (:roles)')
  288.                     ->setParameter('roles'$roles)
  289.                     ->andWhere('users.email IS NOT NULL')
  290.                     ->andWhere('users.email <> \'\'')
  291.                     ->getQuery()
  292.                     ->getResult(SingleColumnHydrator::HYDRATOR)
  293.             )))),
  294.             AbstractRecipient::KINDS__EMAIL
  295.         );
  296.         // get extra phones coming from our data tool
  297.         $metadata $this->em->getClassMetadata(OneRosterUser::class);
  298.         $contacts array_merge(
  299.             $contacts,
  300.             array_fill_keys(
  301.                 array_values(array_unique(array_filter(array_map(
  302.                     function (?string $contact) {
  303.                         return strtolower(trim($contact));
  304.                     },
  305.                     $this->em->getConnection()->executeQuery(
  306.                         sprintf(trim('
  307.                             SELECT
  308.                                 DISTINCT meta.contact
  309.                             FROM
  310.                                 %s AS users, 
  311.                                 JSON_TABLE(
  312.                                     users.%s,
  313.                                     \'$.%s\' COLUMNS (
  314.                                         NESTED PATH \'$[*]\' COLUMNS(
  315.                                             contact VARCHAR(255) PATH \'$\'
  316.                                         )
  317.                                     )
  318.                                 ) meta
  319.                              WHERE
  320.                                     users.%s = ?
  321.                                 AND
  322.                                     meta.contact IS NOT NULL
  323.                                 AND
  324.                                     meta.contact <> \'\'
  325.                             '),
  326.                             $metadata->getTableName(),
  327.                             $metadata->getColumnName('metadata'),
  328.                             self::METADATA__EXTRA_EMAILS,
  329.                             $metadata->getColumnName('tenant'),
  330.                         ),
  331.                         [$job->getTenant()->getId()],
  332.                         [PDO::PARAM_INT],
  333.                     )->fetchFirstColumn()
  334.                 )))),
  335.                 AbstractRecipient::KINDS__EMAIL,
  336.             ),
  337.         );
  338.         // get all the unique landline phones from the stashed data
  339.         // this is done before sms as we want to prefer sms if possible
  340.         $contacts array_merge(
  341.             $contacts,
  342.             array_fill_keys(
  343.                 array_values(array_unique(array_filter(array_map(
  344.                     function (?string $contact) {
  345.                         try {
  346.                             return $this->phones->normalize($contact);
  347.                         } catch (\Exception $e) {
  348.                             // TODO: do we need to maybe log something?
  349.                             return null;
  350.                         }
  351.                     },
  352.                     $this->em->createQueryBuilder()
  353.                         ->select('DISTINCT users.phone')
  354.                         ->from(OneRosterUser::class, 'users')
  355.                         ->andWhere('users.sync = :sync')
  356.                         ->setParameter('sync'$job->getSync())
  357.                         ->andWhere('users.role IN (:roles)')
  358.                         ->setParameter('roles'$roles)
  359.                         ->andWhere('users.phone IS NOT NULL')
  360.                         ->andWhere('users.phone <> \'\'')
  361.                         ->getQuery()
  362.                         ->getResult(SingleColumnHydrator::HYDRATOR)
  363.                 )))),
  364.                 AbstractRecipient::KINDS__VOICE
  365.             )
  366.         );
  367.         // get all the work phone numbers
  368.         foreach (self::METADATA__PHONES as $field => $kind) {
  369.             $contacts array_merge(
  370.                 $contacts,
  371.                 array_fill_keys(
  372.                     array_values(array_unique(array_filter(array_map(
  373.                         function (?string $contact) {
  374.                             // NOTE: the json functions in mysql return a json encoded string
  375.                             $decoded json_decode($contact);
  376.                             if (json_last_error() !== JSON_ERROR_NONE || ! $decoded) {
  377.                                 return null;
  378.                             }
  379.                             try {
  380.                                 return $this->phones->normalize($decoded);
  381.                             } catch (\Exception $e) {
  382.                                 // TODO: do we need to maybe log something?
  383.                                 return null;
  384.                             }
  385.                         },
  386.                         $this->em->createQueryBuilder()
  387.                             ->select(sprintf(
  388.                                 'DISTINCT JSON_EXTRACT(users.metadata, \'$."%s"\') AS phone',
  389.                                 $field
  390.                             ))
  391.                             ->from(OneRosterUser::class, 'users')
  392.                             ->andWhere('users.sync = :sync')
  393.                             ->setParameter('sync'$job->getSync())
  394.                             ->andWhere('users.role IN (:roles)')
  395.                             ->setParameter('roles'$roles)
  396.                             ->andHaving('phone IS NOT NULL')
  397.                             ->andHaving('phone <> \'\'')
  398.                             ->getQuery()
  399.                             ->getResult(SingleColumnHydrator::HYDRATOR)
  400.                     )))),
  401.                     $kind
  402.                 )
  403.             );
  404.         }
  405.         // get extra phones coming from our data tool
  406.         $metadata $this->em->getClassMetadata(OneRosterUser::class);
  407.         $contacts array_merge(
  408.             $contacts,
  409.             array_fill_keys(
  410.                 $this->em->getConnection()->executeQuery(
  411.                     sprintf(trim('
  412.                         SELECT
  413.                             DISTINCT meta.contact
  414.                         FROM
  415.                             %s AS users, 
  416.                             JSON_TABLE(
  417.                                 users.%s,
  418.                                 \'$.%s\' COLUMNS (
  419.                                     NESTED PATH \'$[*]\' COLUMNS(
  420.                                         contact VARCHAR(255) PATH \'$\'
  421.                                     )
  422.                                 )
  423.                             ) meta
  424.                          WHERE
  425.                                 users.%s = ?
  426.                             AND
  427.                                 meta.contact IS NOT NULL
  428.                             AND
  429.                                 meta.contact <> \'\'
  430.                         '),
  431.                         $metadata->getTableName(),
  432.                         $metadata->getColumnName('metadata'),
  433.                         self::METADATA__EXTRA_PHONES,
  434.                         $metadata->getColumnName('tenant'),
  435.                     ),
  436.                     [$job->getTenant()->getId()],
  437.                     [PDO::PARAM_INT],
  438.                 )->fetchFirstColumn(),
  439.                 AbstractRecipient::KINDS__VOICE,
  440.             ),
  441.         );
  442.         // get all the unique cell phones from the stashed data
  443.         // if a number was used for both sms and phone, it will prefer sms
  444.         // this works because we are merging associative arrays
  445.         // duplicates are prevented as contacts are normalized and then merged into existing array
  446.         $contacts array_merge(
  447.             $contacts,
  448.             array_fill_keys(
  449.                 array_values(array_unique(array_filter(array_map(
  450.                     function (?string $contact) {
  451.                         try {
  452.                             return $this->phones->normalize($contact);
  453.                         } catch (\Exception $e) {
  454.                             // TODO: do we need to maybe log something?
  455.                             return null;
  456.                         }
  457.                     },
  458.                     $this->em->createQueryBuilder()
  459.                         ->select('DISTINCT users.sms')
  460.                         ->from(OneRosterUser::class, 'users')
  461.                         ->andWhere('users.sync = :sync')
  462.                         ->setParameter('sync'$job->getSync())
  463.                         ->andWhere('users.role IN (:roles)')
  464.                         ->setParameter('roles'$roles)
  465.                         ->andWhere('users.sms IS NOT NULL')
  466.                         ->andWhere('users.sms <> \'\'')
  467.                         ->getQuery()
  468.                         ->getResult(SingleColumnHydrator::HYDRATOR)
  469.                 )))),
  470.                 AbstractRecipient::KINDS__SMS
  471.             )
  472.         );
  473.         // loop over the object to create the messages to process each fully
  474.         $messages = [];
  475.         $index 0;
  476.         foreach ($contacts as $contact => $type) {
  477.             if ($index++ % AsyncMessage::MAX_BATCH === 0) {
  478.                 $messages[] = [];
  479.             }
  480.             $messages[count($messages) - 1][] = new AsyncMessage(
  481.                 $job,
  482.                 OneRosterEvents::EVENT__PREPARE__ADHOC,
  483.                 [
  484.                     'job' => $job->getId(),
  485.                     'event' => self::ADHOC_EVENTS__CONTACT_PREPARATION,
  486.                     'payload' => [
  487.                         'type' => $type,
  488.                         'contact' => $contact,
  489.                     ],
  490.                 ],
  491.                 self::MQ_PRIORITY,
  492.             );
  493.         }
  494.         // do only if we have messages
  495.         foreach ($messages as $batch) {
  496.             // tracking
  497.             $this->oneroster->orchestratePhaseChange($jobcount($batch));
  498.             // queue them up
  499.             try {
  500.                 $this->async->queue(
  501.                     null,
  502.                     $batch
  503.                 );
  504.             } catch (\Exception $e) {
  505.                 $this->oneroster->orchestratePhaseRevert($jobcount($batch));
  506.                 throw $e;
  507.             }
  508.             // DEBUGGING
  509.             $event->getOutput()->writeln(sprintf(
  510.                 'ADHOC_EVENTS__CONTACT_PREPARATION batch: %s',
  511.                 count($batch)
  512.             ));
  513.         }
  514.         // DEBUGGING
  515.         $event->getOutput()->writeln(sprintf(
  516.             'ADHOC_EVENTS__CONTACT_PREPARATION for %s contact(s) triggered for sync #%s',
  517.             array_sum(array_map('count'$messages)),
  518.             $job->getIdentifier()
  519.         ));
  520.     }
  521.     /**
  522.      * @param OneRosterAdhocEvent $event
  523.      * @return void
  524.      */
  525.     public function contactInjection(OneRosterAdhocEvent $event): void
  526.     {
  527.         // obtain the payload
  528.         $payload $event->getPayload();
  529.         // grab the contact, already normalized
  530.         $contact $payload['contact'];
  531.         // attempt to find an existing recipient
  532.         switch ($payload['type']) {
  533.             case AbstractRecipient::KINDS__EMAIL:
  534.                 $recipient $this->em->getRepository(EmailRecipient::class)->findOneBy([
  535.                     'contact' => $contact,
  536.                 ]);
  537.                 break;
  538.             case AbstractRecipient::KINDS__SMS:
  539.             case AbstractRecipient::KINDS__VOICE:
  540.                 $recipient $this->em->getRepository(PhoneRecipient::class)->findOneBy([
  541.                     'contact' => $contact,
  542.                 ]);
  543.                 break;
  544.             default:
  545.                 throw new \Exception(sprintf(
  546.                     'Cannot find existing contact of type "%s".',
  547.                     $payload['type']
  548.                 ));
  549.         }
  550.         // if no recipient, need to make one
  551.         if ( ! $recipient) {
  552.             // make a fresh object and set specific things
  553.             switch ($payload['type']) {
  554.                 case AbstractRecipient::KINDS__EMAIL:
  555.                     $recipient = (new EmailRecipient());
  556.                     break;
  557.                 case AbstractRecipient::KINDS__SMS:
  558.                     $recipient = (new PhoneRecipient())
  559.                         ->setMethod(PhoneRecipient::METHODS__SMS);
  560.                     break;
  561.                 case AbstractRecipient::KINDS__VOICE:
  562.                     $recipient = (new PhoneRecipient())
  563.                         ->setMethod(PhoneRecipient::METHODS__VOICE);
  564.                     break;
  565.                 default:
  566.                     throw new \Exception(sprintf(
  567.                         'Cannot create contact of type "%s".',
  568.                         $payload['type']
  569.                     ));
  570.             }
  571.             // set common things
  572.             $recipient
  573.                 ->setContact($contact)
  574.                 ->setStanding(Reachability::STANDINGS__UNKNOWN)
  575.                 ->setContactability(Reachability::CONTACTABILITIES__UNKNOWN)
  576.                 ->setReachability(Reachability::REACHABILITIES__OPTIMISTIC);
  577.             // need to save the recipient in the database
  578.             $this->em->save($recipient);
  579.         }
  580.         // TODO: set the id in the payload for easier retrieval later...
  581.     }
  582.     /**
  583.      * @param OneRosterAdhocEvent $event
  584.      * @return void
  585.      */
  586.     public function contactLookup(OneRosterAdhocEvent $event): void
  587.     {
  588.         // TODO: use an id or something to make finding things faster
  589.         // skip this if we are flagged to not do this process
  590.         if ($event->getSync()->hasFlag(OneRosterSync::FLAGS__SKIP_TWILIO_LOOKUPS)) {
  591.             return;
  592.         }
  593.         // obtain the payload
  594.         $payload $event->getPayload();
  595.         // only do for phone types
  596.         if ( ! in_array($payload['type'], [AbstractRecipient::KINDS__SMSAbstractRecipient::KINDS__VOICE])) {
  597.             return;
  598.         }
  599.         // grab the contact, already normalized
  600.         $contact $payload['contact'];
  601.         // attempt to find an existing recipient
  602.         $recipient $this->em->getRepository(PhoneRecipient::class)->findOneBy([
  603.             'contact' => $contact,
  604.         ]);
  605.         // if we do not find anything, we have a problem
  606.         if ( ! $recipient instanceof PhoneRecipient) {
  607.             throw new \Exception();
  608.         }
  609.         // decide if we should skip or not
  610.         switch (true) {
  611.             case $recipient->getLookupStatus() === PhoneRecipient::LOOKUPS__MISSING:
  612.             case $recipient->getLookupStatus() === PhoneRecipient::LOOKUPS__FAILED:
  613.                 $check true;
  614.                 break;
  615.             case $recipient->getLookupStatus() === PhoneRecipient::LOOKUPS__SUCCESSFUL:
  616.                 $check false;
  617.                 break;
  618.             default:
  619.                 throw new \Exception();
  620.         }
  621.         // attempt to do the lookup at twilio if needed
  622.         if ($check) {
  623.             // wrap in try catch in case it fails
  624.             try {
  625.                 // run the api call
  626.                 $result $this->twilio->fallback($recipient)->lookups->v1
  627.                     ->phoneNumbers($this->phones->normalize($recipient->getContact()))
  628.                     ->fetch([
  629.                         'type' => ['carrier'],
  630.                     ]);
  631.                 // update lookup cache
  632.                 $recipient
  633.                     ->setLookup((static function (array $lookup) {
  634.                         unset($lookup['url']);
  635.                         unset($lookup['addOns']);
  636.                         unset($lookup['callerName']);
  637.                         return $lookup;
  638.                     })($result->toArray()))
  639.                     // TODO: need to check the contents to make sure we got good data?
  640.                     ->setLookupStatus(PhoneRecipient::LOOKUPS__SUCCESSFUL)
  641.                     ->setLookupTimestamp(DateTimeUtils::now());
  642.             } catch (\Exception $e) {
  643.                 // mark as failed
  644.                 $recipient
  645.                     ->setLookupStatus(PhoneRecipient::LOOKUPS__FAILED)
  646.                     ->setLookupTimestamp(DateTimeUtils::now());
  647.                 // log an error
  648.                 $this->oneroster->logIssue(
  649.                     $event->getJob(),
  650.                     OneRosterJob::PHASES__PREPARE,
  651.                     self::ADHOC_EVENTS__CONTACT_PREPARATION,
  652.                     PhoneRecipient::DISCR,
  653.                     $recipient->getUidString(),
  654.                     $e
  655.                 );
  656.             }
  657.         }
  658.         // grab the lookup data
  659.         $lookup $recipient->getLookup();
  660.         // set the type, prefer lookup information if available
  661.         if (is_array($lookup) && array_key_exists('carrier'$lookup) && is_array($lookup['carrier']) && array_key_exists('type'$lookup['carrier']) && isset($lookup['carrier']['type'])) {
  662.             switch ($lookup['carrier']['type']) {
  663.                 case 'mobile':
  664.                     $recipient->setType(PhoneRecipient::TYPES__MOBILE);
  665.                     break;
  666.                 case 'landline':
  667.                     $recipient->setType(PhoneRecipient::TYPES__LANDLINE);
  668.                     break;
  669.                 case 'voip':
  670.                     $recipient->setType(PhoneRecipient::TYPES__VOIP);
  671.                     break;
  672.             }
  673.         }
  674.         // set the method, prefer lookup information if available
  675.         if (is_array($lookup) && array_key_exists('carrier'$lookup) && is_array($lookup['carrier']) && array_key_exists('type'$lookup['carrier']) && isset($lookup['carrier']['type'])) {
  676.             switch ($lookup['carrier']['type']) {
  677.                 case 'mobile':
  678.                     // TODO: should we use hybrid or sms here?
  679.                     $recipient->setMethod(PhoneRecipient::METHODS__HYBRID);
  680.                     break;
  681.                 case 'landline':
  682.                 case 'voip':
  683.                     $recipient->setMethod(PhoneRecipient::METHODS__VOICE);
  684.                     break;
  685.             }
  686.         }
  687.         // save changes
  688.         $this->em->save($recipient);
  689.     }
  690. }