src/Products/NotificationsBundle/Subscriber/OneRoster/OneRosterUserProcessSubscriber.php line 212

Open in your IDE?
  1. <?php
  2. namespace Products\NotificationsBundle\Subscriber\OneRoster;
  3. use App\Service\Data\PhoneNumberService;
  4. use Cms\CoreBundle\Entity\OneRoster\OneRosterUser;
  5. use Cms\CoreBundle\Entity\OneRosterJob;
  6. use Cms\CoreBundle\Entity\OneRosterSync;
  7. use Cms\CoreBundle\Events\OneRosterProcessEvent;
  8. use Cms\CoreBundle\Service\OneRosterService;
  9. use Cms\CoreBundle\Util\DateTimeUtils;
  10. use Cms\CoreBundle\Util\Doctrine\EntityManager;
  11. use DateTime;
  12. use Doctrine\Common\Util\ClassUtils;
  13. use Products\NotificationsBundle\Entity\AbstractRecipient;
  14. use Products\NotificationsBundle\Entity\Profile;
  15. use Products\NotificationsBundle\Entity\ProfileContact;
  16. use Products\NotificationsBundle\Entity\Recipients\EmailRecipient;
  17. use Products\NotificationsBundle\Entity\Recipients\PhoneRecipient;
  18. use Products\NotificationsBundle\Entity\Student;
  19. use Products\NotificationsBundle\Util\Preferences;
  20. use Products\NotificationsBundle\Util\Reachability;
  21. use Symfony\Contracts\Translation\TranslatorInterface;
  22. final class OneRosterUserProcessSubscriber extends AbstractNotificationsOneRosterSubscriber
  23. {
  24.     // DI
  25.     protected PhoneNumberService $phones;
  26.     /**
  27.      * {@inheritdoc}
  28.      * @param PhoneNumberService $phones
  29.      */
  30.     public function __construct(
  31.         EntityManager $em,
  32.         OneRosterService $oneroster,
  33.         TranslatorInterface $translator,
  34.         PhoneNumberService $phones
  35.     )
  36.     {
  37.         parent::__construct($em$oneroster$translator);
  38.         $this->phones $phones;
  39.     }
  40.     /**
  41.      * {@inheritdoc}
  42.      */
  43.     public static function getSubscribedEvents(): array
  44.     {
  45.         return [
  46.             OneRosterProcessEvent::EVENT__USER => [
  47.                 ['syncProfile'0],
  48.                 ['syncStudent'0],
  49.                 ['syncRecipients'0],
  50.                 ['initPreferences'0],
  51.                 ['calculateReachability'0],
  52.             ],
  53.         ];
  54.     }
  55.     /**
  56.      * @param OneRosterProcessEvent $event
  57.      */
  58.     public function syncProfile(OneRosterProcessEvent $event): void
  59.     {
  60.         // ensure we are meant to process this
  61.         if ( ! $this->checkTypes($event->getJob(), [
  62.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF,
  63.             OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY,
  64.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS,
  65.             OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY,
  66.         ])) {
  67.             return;
  68.         }
  69.         // get the user
  70.         $user $event->getEntity();
  71.         if ( ! $user instanceof OneRosterUser) {
  72.             throw new \RuntimeException(sprintf(
  73.                 'User is not of proper type, got "%s".',
  74.                 ClassUtils::getClass($user)
  75.             ));
  76.         }
  77.         // branch on the role type
  78.         switch (true) {
  79.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF]) && $user->isRoleStaff():
  80.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY]) && $user->isRoleFamily():
  81.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS]) && $user->isRoleStudent():
  82.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY]) && $user->isRoleCommunity():
  83.                 // noop, code will continue after the switch statement
  84.                 break;
  85.             default:
  86.                 // DEBUGGING
  87.                 $event->getOutput()->writeln(sprintf(
  88.                     'User role for #%s is "%s", skipping...',
  89.                     $user->getSourcedId(),
  90.                     $user->getRole()
  91.                 ));
  92.                 // return to prevent further processing
  93.                 return;
  94.         }
  95.         // attempt to find a profile, first by oneroster id
  96.         $profile $this->em->getRepository(Profile::class)->findOneBy([
  97.             'onerosterId' => $user->getSourcedId(),
  98.         ]);
  99.         // IMPORTANT: if we don't find by oneroster id, we cannot look up any other way as it is not safe/private
  100.         // generate a new profile if we don't have one
  101.         if ( ! $profile) {
  102.             $profile = new Profile();
  103.         }
  104.         // determine the usability of the user
  105.         // if we are to ignore the enabledUser property (different schools have different takes on this), then we just use the active status
  106.         // otherwise, we need to incorporate that enabledUser property into our calculations
  107.         $usable $event->getSync()->hasFlag(OneRosterSync::FLAGS__IGNORE_ENABLED_USER_PROPERTY)
  108.             ? $user->isStatusActive()
  109.             : $user->isUsable();
  110.         // clear discarded just in case it was previously set
  111.         // do only if the user is active according to oneroster spec
  112.         // else, use whatever status was currently set on the
  113.         $profile->setDiscardedAt(
  114.             $usable
  115.                 null
  116.                 : ($profile->getDiscardedAt() ?? new DateTime())
  117.         );
  118.         // set stuff
  119.         $profile
  120.             ->setFirstName($user->getGivenName())
  121.             ->setLastName($user->getFamilyName())
  122.             ->setRole($user->getRole())
  123.             ->setOneRosterId($user->getSourcedId())
  124.         ;
  125.         // compile orgs
  126.         $orgs array_values(array_unique(array_merge(
  127.             $user->hasMetadataEntry('gg4l.primary_school')
  128.                 ? [$user->getMetadataEntry('gg4l.primary_school')]
  129.                 : [],
  130.             $user->getOrgsSourcedIds(),
  131.         )));
  132.         if ($event->getSync()->hasFlag(OneRosterSync::FLAGS__SINGLE_SCHOOL)) {
  133.             $orgs = [$event->getSync()->getDistrictId()];
  134.         }
  135.         // handle core metadata
  136.         $metadata = [
  137.             '_role' => $user->getRole(),
  138.             '_role_type' => OneRosterUser::TYPES_LOOKUP[
  139.                 OneRosterUser::ROLES_MAPPING[$user->getRole()]
  140.             ],
  141.             '_orgs' => $orgs,
  142.             '_schools' => array_values(array_diff(
  143.                 $orgs,
  144.                 [$event->getSync()->getDistrictId()],
  145.             )),
  146.             '_district' => $event->getSync()->getDistrictId(),
  147.             '_grades' => array_values(array_unique(array_merge(
  148.                 $user->hasMetadataEntry('gg4l.primaryGrade')
  149.                     ? [$user->getMetadata()['gg4l.primaryGrade']]
  150.                     : [],
  151.                 $user->getGrades(),
  152.             ))),
  153.         ];
  154.         // handle metadata
  155.         $profile->setMetadata(array_merge(
  156.             $user->getMetadata() ?: [],
  157.             $metadata,
  158.         ));
  159.         // handle language setting if passed with metadata
  160.         // this needs done after the metadata has been set as it could change metadata
  161.         try {
  162.             $profile->setLanguage(
  163.                 $user->hasMetadataEntry(Profile::METADATA__LANGUAGE)
  164.                     ? $user->getMetadataEntry(Profile::METADATA__LANGUAGE)
  165.                     : null
  166.             );
  167.         } catch (\Exception $e) {
  168.             $profile->setLanguage(null);
  169.         }
  170.         // track archived status
  171.         $profile->setOneRosterArchived($user->isStatusToBeDeleted());
  172.         // DEBUGGING
  173.         $event->getOutput()->writeln(sprintf(
  174.             '    %s    %s (%s | %s | %s)',
  175.             (empty($profile->getId())) ? 'Generating' 'Updating',
  176.             ClassUtils::getClass($profile),
  177.             $profile->getLastName(),
  178.             $profile->getOneRosterId(),
  179.             $profile->getId() ?: '-'
  180.         ));
  181.         // save it
  182.         $this->em->save($profile);
  183.     }
  184.     /**
  185.      * @param OneRosterProcessEvent $event
  186.      */
  187.     public function syncStudent(OneRosterProcessEvent $event): void
  188.     {
  189.         // ensure we are meant to process this
  190.         if ( ! $this->checkTypes($event->getJob(), [
  191.             OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY,
  192.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS,
  193.         ])) {
  194.             return;
  195.         }
  196.         // get the user
  197.         $user $event->getEntity();
  198.         if ( ! $user instanceof OneRosterUser) {
  199.             throw new \RuntimeException(sprintf(
  200.                 'User is not of proper type, got "%s".',
  201.                 ClassUtils::getClass($user)
  202.             ));
  203.         }
  204.         // branch on the role type
  205.         switch (true) {
  206.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY]) && $user->isRoleStudent():
  207.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS]) && $user->isRoleStudent():
  208.                 // noop, code will continue after the switch statement
  209.                 break;
  210.             default:
  211.                 // DEBUGGING
  212.                 $event->getOutput()->writeln(sprintf(
  213.                     'User role for #%s is "%s", skipping...',
  214.                     $user->getSourcedId(),
  215.                     $user->getRole()
  216.                 ));
  217.                 // return to prevent further processing
  218.                 return;
  219.         }
  220.         // attempt to find a student, first by oneroster id
  221.         $student $this->em->getRepository(Student::class)->findOneBy([
  222.             'onerosterId' => $user->getSourcedId(),
  223.         ]);
  224.         // IMPORTANT: if we don't find by oneroster id, we cannot look up any other way as it is not safe/private
  225.         // generate a new profile if we don't have one
  226.         if ( ! $student) {
  227.             $student = new Student();
  228.         }
  229.         // determine the usability of the user
  230.         // if we are to ignore the enabledUser property (different schools have different takes on this), then we just use the active status
  231.         // otherwise, we need to incorporate that enabledUser property into our calculations
  232.         $usable $event->getSync()->hasFlag(OneRosterSync::FLAGS__IGNORE_ENABLED_USER_PROPERTY)
  233.             ? $user->isStatusActive()
  234.             : $user->isUsable();
  235.         // clear discarded just in case it was previously set
  236.         // do only if the user is active according to oneroster spec
  237.         // else, use whatever status was currently set on the
  238.         $student->setDiscardedAt(
  239.             $usable
  240.                 null
  241.                 : ($student->getDiscardedAt() ?? new DateTime())
  242.         );
  243.         // set stuff
  244.         $student
  245.             ->setFirstName($user->getGivenName())
  246.             ->setLastName($user->getFamilyName())
  247.             ->setOneRosterId($user->getSourcedId())
  248.         ;
  249.         // compile orgs
  250.         $orgs array_values(array_unique(array_merge(
  251.             $user->hasMetadataEntry('gg4l.primary_school')
  252.                 ? [$user->getMetadataEntry('gg4l.primary_school')]
  253.                 : [],
  254.             $user->getOrgsSourcedIds(),
  255.         )));
  256.         if ($event->getSync()->hasFlag(OneRosterSync::FLAGS__SINGLE_SCHOOL)) {
  257.             $orgs = [$event->getSync()->getDistrictId()];
  258.         }
  259.         // handle core metadata
  260.         $metadata = [
  261.             '_role' => $user->getRole(),
  262.             '_role_type' => OneRosterUser::TYPES_LOOKUP[
  263.                 OneRosterUser::ROLES_MAPPING[$user->getRole()]
  264.             ],
  265.             '_orgs' => $orgs,
  266.             '_schools' => array_values(array_diff(
  267.                 $orgs,
  268.                 [$event->getSync()->getDistrictId()],
  269.             )),
  270.             '_district' => $event->getSync()->getDistrictId(),
  271.             '_grades' => array_values(array_unique(array_merge(
  272.                 $user->hasMetadataEntry('gg4l.primaryGrade')
  273.                     ? [$user->getMetadata()['gg4l.primaryGrade']]
  274.                     : [],
  275.                 $user->getGrades(),
  276.             ))),
  277.         ];
  278.         // handle metadata
  279.         $student->setMetadata(array_merge(
  280.             $user->getMetadata() ?: [],
  281.             $metadata
  282.         ));
  283.         // track archived status
  284.         $student->setOneRosterArchived($user->isStatusToBeDeleted());
  285.         // DEBUGGING
  286.         $event->getOutput()->writeln(sprintf(
  287.             '    %s    %s (%s | %s | %s)',
  288.             (empty($student->getId())) ? 'Generating' 'Updating',
  289.             ClassUtils::getClass($student),
  290.             $student->getCensoredName(),
  291.             $student->getOneRosterId(),
  292.             $student->getId() ?: '-'
  293.         ));
  294.         // save it
  295.         $this->em->save($student);
  296.     }
  297.     /**
  298.      * @param OneRosterProcessEvent $event
  299.      */
  300.     public function syncRecipients(OneRosterProcessEvent $event): void
  301.     {
  302.         // ensure we are meant to process this
  303.         if ( ! $this->checkTypes($event->getJob(), [
  304.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF,
  305.             OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY,
  306.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS,
  307.             OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY,
  308.         ])) {
  309.             return;
  310.         }
  311.         // get the user
  312.         $user $event->getEntity();
  313.         if ( ! $user instanceof OneRosterUser) {
  314.             throw new \RuntimeException(sprintf(
  315.                 'User is not of proper type, got "%s".',
  316.                 ClassUtils::getClass($user)
  317.             ));
  318.         }
  319.         // branch on the role type
  320.         switch (true) {
  321.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF]) && $user->isRoleStaff():
  322.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY]) && $user->isRoleFamily():
  323.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS]) && $user->isRoleStudent():
  324.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY]) && $user->isRoleCommunity():
  325.                 // noop, code will continue after the switch statement
  326.                 break;
  327.             default:
  328.                 // DEBUGGING
  329.                 $event->getOutput()->writeln(sprintf(
  330.                     'User role for #%s is "%s", skipping...',
  331.                     $user->getSourcedId(),
  332.                     $user->getRole()
  333.                 ));
  334.                 // quit early
  335.                 return;
  336.         }
  337.         // load up the profile, this must match something, and it should at this point...
  338.         $profile $this->em->getRepository(Profile::class)->findOneBy([
  339.             'onerosterId' => $user->getSourcedId(),
  340.         ]);
  341.         // if we don't have a profile we have a problem
  342.         if ( ! $profile) {
  343.             throw new \RuntimeException(sprintf(
  344.                 'Could not load profile "%s".',
  345.                 $user->getSourcedId()
  346.             ));
  347.         }
  348.         // assemble all the pieces of contact information we can find
  349.         $values = [];
  350.         // attempt to pull the standard email field
  351.         if ($user->getEmail()) {
  352.             $values[strtolower($user->getEmail())] = AbstractRecipient::KINDS__EMAIL;
  353.         }
  354.         // handle extra emails coming from our own data tool
  355.         if ($user->hasMetadataEntry(OneRosterPrepareSubscriber::METADATA__EXTRA_EMAILS)) {
  356.             foreach ($user->getMetadataEntry(OneRosterPrepareSubscriber::METADATA__EXTRA_EMAILS) as $email) {
  357.                 $values[strtolower($email)] = AbstractRecipient::KINDS__EMAIL;
  358.             }
  359.         }
  360.         // attempt to pull the standard phone field
  361.         if ($user->getPhone()) {
  362.             try {
  363.                 $val $this->phones->normalize(
  364.                     $user->getPhone()
  365.                 );
  366.                 if ($val) {
  367.                     $values[$val] = AbstractRecipient::KINDS__VOICE;
  368.                 }
  369.             } catch (\Exception $e) {
  370.                 $this->oneroster->logIssue(
  371.                     $event->getJob(),
  372.                     OneRosterJob::PHASES__PROCESS,
  373.                     OneRosterProcessEvent::EVENTS[ClassUtils::getClass($user)],
  374.                     $user::ONEROSTER_TYPE,
  375.                     $user,
  376.                     $e
  377.                 );
  378.             }
  379.         }
  380.         // attempt to pull the work phone stuff
  381.         // assume voice as work phones tend to be landlines?
  382.         foreach (OneRosterPrepareSubscriber::METADATA__PHONES as $field => $kind) {
  383.             if ($user->hasMetadataEntry($field)) {
  384.                 try {
  385.                     $val $this->phones->normalize(
  386.                         $user->getMetadataEntry($field)
  387.                     );
  388.                     if ($val) {
  389.                         $values[$val] = $kind;
  390.                     }
  391.                 } catch (\Exception $e) {
  392.                     $this->oneroster->logIssue(
  393.                         $event->getJob(),
  394.                         OneRosterJob::PHASES__PROCESS,
  395.                         OneRosterProcessEvent::EVENTS[ClassUtils::getClass($user)],
  396.                         $user::ONEROSTER_TYPE,
  397.                         $user,
  398.                         $e
  399.                     );
  400.                 }
  401.             }
  402.         }
  403.         // handle extra phones coming from our own data tool
  404.         if ($user->hasMetadataEntry(OneRosterPrepareSubscriber::METADATA__EXTRA_PHONES)) {
  405.             foreach ($user->getMetadataEntry(OneRosterPrepareSubscriber::METADATA__EXTRA_PHONES) as $phone) {
  406.                 try {
  407.                     $val $this->phones->normalize($phone);
  408.                     if ($val) {
  409.                         $values[$val] = AbstractRecipient::KINDS__VOICE;
  410.                     }
  411.                 } catch (\Exception $e) {
  412.                     $this->oneroster->logIssue(
  413.                         $event->getJob(),
  414.                         OneRosterJob::PHASES__PROCESS,
  415.                         OneRosterProcessEvent::EVENTS[ClassUtils::getClass($user)],
  416.                         $user::ONEROSTER_TYPE,
  417.                         $user,
  418.                         $e
  419.                     );
  420.                 }
  421.             }
  422.         }
  423.         // attempt to pull the standard sms field
  424.         // NOTE: do this after phone in case the same number is used as voice, so we can prefer sms
  425.         if ($user->getSms()) {
  426.             try {
  427.                 $val $this->phones->normalize(
  428.                     $user->getSms()
  429.                 );
  430.                 if ($val) {
  431.                     $values[$val] = AbstractRecipient::KINDS__SMS;
  432.                 }
  433.             } catch (\Exception $e) {
  434.                 $this->oneroster->logIssue(
  435.                     $event->getJob(),
  436.                     OneRosterJob::PHASES__PROCESS,
  437.                     OneRosterProcessEvent::EVENTS[ClassUtils::getClass($user)],
  438.                     $user::ONEROSTER_TYPE,
  439.                     $user,
  440.                     $e
  441.                 );
  442.             }
  443.         }
  444.         // now that all the contacts are sure to be in the database, pull them
  445.         // must pull them by their value
  446.         $contacts $this->em->getRepository(AbstractRecipient::class)->findBy([
  447.             'contact' => array_keys($values),
  448.         ]);
  449.         if (count($contacts) !== count($values)) {
  450.             throw new \RuntimeException(sprintf(
  451.                 'Could not load all contact information, missing: "%s".',
  452.                 implode('", "'array_diff(
  453.                     array_map(
  454.                         static function (AbstractRecipient $contact) {
  455.                             return $contact->getContact();
  456.                         },
  457.                         $contacts
  458.                     ),
  459.                     array_keys($values)
  460.                 ))
  461.             ));
  462.         }
  463.         // get all the existing contacts in our system for this person
  464.         /** @var array|ProfileContact[] $xrefs */
  465.         $xrefs $this->em->getRepository(ProfileContact::class)->findBy([
  466.             'profile' => $profile,
  467.         ]);
  468.         // track which items need to be kept in the database
  469.         $managed = [];
  470.         // need to loop over the values as those are the things we have to track
  471.         foreach ($values as $value => $kind) {
  472.             // if no value, we skip
  473.             if ( ! $value) {
  474.                 continue;
  475.             }
  476.             // match to a contact
  477.             $contact null;
  478.             foreach ($contacts as $thing) {
  479.                 // TODO: should we do a more flexible string comparison?
  480.                 if ($thing->getContact() === $value) {
  481.                     $contact $thing;
  482.                     break;
  483.                 }
  484.             }
  485.             if ( ! $contact) {
  486.                 throw new \RuntimeException(sprintf(
  487.                     'Could not find contact match for value "%s".',
  488.                     $value
  489.                 ));
  490.             }
  491.             // try to find a xref
  492.             // loop over all the existing xrefs and attempt to find one that matches
  493.             $xref null;
  494.             foreach ($xrefs as $thing) {
  495.                 if ($thing->getRecipient() === $contact) {
  496.                     $xref $thing;
  497.                     break;
  498.                 }
  499.             }
  500.             // if none was found, we need to make one
  501.             if ( ! $xref) {
  502.                 // generate a xref based on type/kind
  503.                 $xref = new ProfileContact();
  504.                 // we made a new one, need to also attach it to the xrefs
  505.                 $xrefs[] = $xref;
  506.             }
  507.             // attach the contact to the managed entities
  508.             if ( ! array_search($xref$managedtrue)) {
  509.                 $managed[] = $xref;
  510.             }
  511.             // set data
  512.             $xref
  513.                 ->setProfile($profile)
  514.                 ->setRecipient($contact)
  515.                 ->setOneRosterId($user->getSourcedId())
  516.             ;
  517.             // handle discarded
  518.             $xref->setDiscardedAt(
  519.                 $user->isStatusActive()
  520.                     ? null
  521.                     $xref->getDiscardedAt()
  522.             );
  523.             // determine if archived or not
  524.             $xref->setOneRosterArchived($user->isStatusToBeDeleted());
  525.         }
  526.         // DEBUGGING
  527.         array_walk($managed, static function (?ProfileContact $xref) use ($event) {
  528.             if ($xref) {
  529.                 $event->getOutput()->writeln(sprintf(
  530.                     '    %s    %s (%s >> %s %s | %s | %s)',
  531.                     (empty($xref->getId())) ? 'Generating' 'Updating',
  532.                     ClassUtils::getClass($xref),
  533.                     $xref->getProfile()->getId(),
  534.                     $xref->getRecipient()->getId(),
  535.                     spl_object_hash($xref),
  536.                     $xref->getOneRosterId(),
  537.                     $xref->getId() ?: '-'
  538.                 ));
  539.             }
  540.         });
  541.         // go ahead and save the things we know want to keep
  542.         // this allows us to get ids that are needed in the following checks
  543.         $this->em->saveAll($managed);
  544.         // make a diff and reset contacts to those that are not matched
  545.         // these will need trashed as they are extra and do not match what is in the record from oneroster
  546.         $extras array_udiff($xrefs$managed, static function (ProfileContact $aProfileContact $b) {
  547.             return ($a === $b) ? $a->getId() <=> $b->getId();
  548.         });
  549.         // need to determine which ones are actually to be discarded and which ones were manually added.
  550.         // manually added contacts should not have an id from oneroster tied to them
  551.         // NOTE: contacts added manually then found via oneroster will be attached to oneroster data and will be subject to syncing rules
  552.         foreach ($extras as $extra) {
  553.             // if there is oneroster id, we want to discard
  554.             // this means the contact is managed via oneroster and if we make it this far, the contact did not match current data
  555.             // if there is no oneroster id, it was manually added through the portal
  556.             // these items need kept around
  557.             $extra->setDiscardedAt(
  558.                 $extra->getOneRosterId()
  559.                     // preserve the original discard stamp if there is one
  560.                     ? ($extra->getDiscardedAt() ?: DateTimeUtils::now())
  561.                     : ($extra->getDiscardedAt() ?: null)
  562.             );
  563.         }
  564.         // DEBUGGING
  565.         array_walk($extras, static function (ProfileContact $xref) use ($event$profile) {
  566.             try {
  567.                 $event->getOutput()->writeln(sprintf(
  568.                     '    %s    %s (%s >> %s %s | %s | %s)',
  569.                     ($xref->isDiscarded()) ? 'Discarding' 'Updating',
  570.                     ClassUtils::getClass($xref),
  571.                     $xref->getProfile()->getId(),
  572.                     ($xref->getRecipient()) ? $xref->getRecipient()->getId() : '?',
  573.                     spl_object_hash($xref),
  574.                     $xref->getOneRosterId(),
  575.                     $xref->getId() ?: '-'
  576.                 ));
  577.             } catch (\Throwable $e) {
  578.                 // TODO: remove this at some point if it appears to no longer be needed...
  579.                 throw new \RuntimeException(sprintf(
  580.                     'XREF ID: %s | XREF OR: %s | XREF PRO ID: %s | XREF REC ID: %s | PRO ID: %s',
  581.                     $xref->getId() ?: '-',
  582.                     $xref->getOneRosterId() ?: '-',
  583.                     $xref->getProfile() ? $xref->getProfile()->getId() : '-',
  584.                     $xref->getRecipient() ? $xref->getRecipient()->getId() : '-',
  585.                     $profile->getId() ?: '-'
  586.                 ), 0$e);
  587.             }
  588.         });
  589.         // save changes
  590.         // discarded things will be filtered out by query filters
  591.         $this->em->saveAll($extras);
  592.     }
  593.     /**
  594.      * @param OneRosterProcessEvent $event
  595.      */
  596.     public function initPreferences(OneRosterProcessEvent $event): void
  597.     {
  598.         // ensure we are meant to process this
  599.         if ( ! $this->checkTypes($event->getJob(), [
  600.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF,
  601.             OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY,
  602.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS,
  603.             OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY,
  604.         ])) {
  605.             return;
  606.         }
  607.         // get the user
  608.         $user $event->getEntity();
  609.         if ( ! $user instanceof OneRosterUser) {
  610.             throw new \RuntimeException(sprintf(
  611.                 'User is not of proper type, got "%s".',
  612.                 ClassUtils::getClass($user)
  613.             ));
  614.         }
  615.         // branch on the role type
  616.         switch (true) {
  617.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF]) && $user->isRoleStaff():
  618.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY]) && $user->isRoleFamily():
  619.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS]) && $user->isRoleStudent():
  620.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY]) && $user->isRoleCommunity():
  621.                 // noop, code will continue after the switch statement
  622.                 break;
  623.             default:
  624.                 // DEBUGGING
  625.                 $event->getOutput()->writeln(sprintf(
  626.                     'User role for #%s is "%s", skipping...',
  627.                     $user->getSourcedId(),
  628.                     $user->getRole()
  629.                 ));
  630.                 // quit early
  631.                 return;
  632.         }
  633.         // load up the profile, this must match something, and it should at this point...
  634.         $profile $this->em->getRepository(Profile::class)->findOneBy([
  635.             'onerosterId' => $user->getSourcedId(),
  636.         ]);
  637.         // if we don't have a profile we have a problem
  638.         if ( ! $profile) {
  639.             throw new \RuntimeException(sprintf(
  640.                 'Could not load profile "%s".',
  641.                 $user->getSourcedId()
  642.             ));
  643.         }
  644.         // get all the contacts
  645.         // we are pulling the assocs too
  646.         // IMPORTANT: be sure to force it to pull a fresh batch; or just grab from the em maybe...
  647.         /** @var array|ProfileContact[] $contacts */
  648.         $contacts $this->em->getRepository(ProfileContact::class)->findBy([
  649.             'profile' => $profile,
  650.         ]);
  651.         // if no contacts, skip
  652.         if ( ! $contacts) {
  653.             return;
  654.         }
  655.         // loop over each contact and enable each one that applies
  656.         foreach ($contacts as $contact) {
  657.             // if the contact has already fiddled with preferences, we need to skip this in order to preserve their settings
  658.             // NOTE: we used to do this for "discarded" ones too, but in case there is a temporary data issue, we don't want to be clearing settings for people (after seeing what confusion that causes)
  659.             if ( ! $contact->isManaged()) {
  660.                 // get the recipient
  661.                 $recipient $contact->getRecipient();
  662.                 // do the initial setting
  663.                 switch (true) {
  664.                     case $recipient instanceof EmailRecipient:
  665.                         $contact
  666.                             ->setPrimaryPreference(Preferences::PREFERENCES__EMAILtrue)
  667.                             ->setSecondaryPreference(Preferences::PREFERENCES__EMAILtrue)
  668.                         ;
  669.                         break;
  670.                     case $recipient instanceof PhoneRecipient && $recipient->isSms():
  671.                         $contact->setPrimaryPreference(Preferences::PREFERENCES__SMStrue);
  672.                         // HACK: allow defaulting of secondary sms consent to a different value if provided by customer
  673.                         // NOTE: this should require the customer to show proper documentation and gathering of the consent!!!
  674.                         $contact->setSecondaryPreference(
  675.                             Preferences::PREFERENCES__SMS,
  676.                             ($user->hasMetadataEntry('_secondary_sms_consent') && ($user->getMetadataEntry('_secondary_sms_consent') === true)),
  677.                         );
  678.                         break;
  679.                     case $recipient instanceof PhoneRecipient && $recipient->isVoice():
  680.                         $contact->setPrimaryPreference(Preferences::PREFERENCES__VOICEtrue);
  681.                         // TODO: should we force the voice preference here to false?
  682.                         break;
  683.                 }
  684.             }
  685.             // pick only one phone type when multiple are there
  686.             // prefer sms
  687.             if ($contact->hasPrimarySmsPreference() && $contact->hasPrimaryVoicePreference()) {
  688.                 $contact->setPrimaryPreference(
  689.                     Preferences::PREFERENCES__VOICE,
  690.                     false
  691.                 );
  692.             }
  693.         }
  694.         // save the changes
  695.         $this->em->saveAll(
  696.             $contacts
  697.         );
  698.     }
  699.     /**
  700.      * @param OneRosterProcessEvent $event
  701.      */
  702.     public function calculateReachability(OneRosterProcessEvent $event): void
  703.     {
  704.         // ensure we are meant to process this
  705.         if ( ! $this->checkTypes($event->getJob(), [
  706.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF,
  707.             OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY,
  708.             OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS,
  709.             OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY,
  710.         ])) {
  711.             return;
  712.         }
  713.         // get the user
  714.         $user $event->getEntity();
  715.         if ( ! $user instanceof OneRosterUser) {
  716.             throw new \RuntimeException(sprintf(
  717.                 'User is not of proper type, got "%s".',
  718.                 ClassUtils::getClass($user)
  719.             ));
  720.         }
  721.         // branch on the role type
  722.         switch (true) {
  723.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STAFF]) && $user->isRoleStaff():
  724.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__FAMILY]) && $user->isRoleFamily():
  725.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__STUDENTS]) && $user->isRoleStudent():
  726.             case $this->checkTypes($event->getJob(), [OneRosterSync::STRATEGIES__NOTIFICATIONS__COMMUNITY]) && $user->isRoleCommunity():
  727.                 // noop, code will continue after the switch statement
  728.                 break;
  729.             default:
  730.                 // DEBUGGING
  731.                 $event->getOutput()->writeln(sprintf(
  732.                     'User role for #%s is "%s", skipping...',
  733.                     $user->getSourcedId(),
  734.                     $user->getRole()
  735.                 ));
  736.                 // quit early
  737.                 return;
  738.         }
  739.         // load up the profile, this must match something, and it should at this point...
  740.         $profile $this->em->getRepository(Profile::class)->findOneBy([
  741.             'onerosterId' => $user->getSourcedId(),
  742.         ]);
  743.         // if we don't have a profile we have a problem
  744.         if ( ! $profile) {
  745.             throw new \RuntimeException(sprintf(
  746.                 'Could not load profile "%s".',
  747.                 $user->getSourcedId()
  748.             ));
  749.         }
  750.         // get all the contacts
  751.         // IMPORTANT: be sure to force it to pull a fresh batch; or just grab from the em maybe...
  752.         /** @var array|AbstractRecipient[] $contacts */
  753.         $contacts $this->em->getRepository(AbstractRecipient::class)->findByProfile(
  754.             $profile
  755.         );
  756.         // flatten to one standing
  757.         $standing Reachability::STANDINGS__NONE;
  758.         foreach ($contacts as $contact) {
  759.             $standing |= $contact->getStanding();
  760.         }
  761.         // set the standing and save
  762.         $this->em->save(
  763.             $profile
  764.                 ->setStanding($standing)
  765.         );
  766.     }
  767. }