Jake Vanderwerf
5 days ago 0dfe1d8afafc59c4a5559c498342668d5a58d6ef
inc/integrations/SyncTo.php
@@ -14,6 +14,20 @@
   exit;
}
/**
 * For Syncing to a service, the basic flow is:
 * 1) Queuing an operation
 *       a) using the $syncTo as the operation type
 *       b) the $data has:
 *          i) key of 'posts', 'terms', 'users' depending on the source data
 *          ii) key of 'user' to set user's specific integration connection, or the site-wide connection
 * 2) IntegrationExecutor then determines if it is a sync to operation
 *    -> from there, it calls the integrations {action}BatchToService or {create}OneToService
 *
 * Most integrations will just need to set the $hasBatchUpdate, $hasBatchDelete, $hasBatchCreate flags to signal we can condense it into a single request,
 *    set the formatForService() method if anything deviates from the defaults
 *    and the endpoint for a particular action
 */
trait SyncTo {
   use SyncHelpers, UserConnection;
   /**
@@ -29,7 +43,7 @@
    * Defaults to an array of formatted items. Can be overridden in child classes
    * @param array $itemIDs
    * @param string $type
    * @return array
    * @return array Either an array of formatted items, or a single formatted item (if there is 1)
    */
   protected function formatItems(array $itemIDs, string $type):array
   {
@@ -45,14 +59,32 @@
            ]);
         }
      }
      return $items;
      return count($items) === 1 ? $items[0] : $items;
   }
   protected function determineType(array $data):string|false
   {
      $type = false;
      if (array_key_exists('posts', $data)) {
         $type = 'post';
      } else if (array_key_exists('terms', $data)) {
         $type = 'term';
      } else if (array_key_exists('users', $data)) {
         $type = 'user';
      }
      return $type;
   }
   /***************************************************************
    * Item creation
   ***************************************************************/
   public function createBatchToService($data):array
   {
      $type = $data['type']??'post';
      $type = $this->determineType($data);
      if (!$type) {
         $this->logError('createBatchToService', 'No expected keys in data', ['data' => $data]);
         return $this->noCreatedItems([]);
      }
      //Check if any of the submitted ids are already created
      $created = array_filter($data['items'],
@@ -97,7 +129,7 @@
      } else {
         $success = $errors = [];
         foreach ($items as $item) {
            $itemResult = $this->createOne($item);
            $itemResult = $this->createOneToService($item);
            if (!is_wp_error($itemResult)) {
               $success[] = $itemResult;
            } else {
@@ -121,9 +153,9 @@
    * @param array $item
    * @return array|WP_Error
    */
   protected function createOne(array $item):array|WP_Error
   protected function createOneToService(array $item):array|WP_Error
   {
      return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own createOne');
      return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own createOneToService');
   }
   /**
@@ -157,7 +189,12 @@
   *****************************************************************/
   public function updateBatchToService(array $data):array
   {
      $type = $data['type']??'post';
      $type = $this->determineType($data);
      if (!$type) {
         $this->logError('createBatchToService', 'No expected keys in data', ['data' => $data]);
         return $this->noUpdatedItems([]);
      }
      $newlyCreated = [];
      if (!$this->canCreateOnUpdate) {
@@ -211,7 +248,7 @@
         $success = $errors = [];
         //Does not have batch update, manually update each one
         foreach ($items as $item) {
            $itemResult = $this->updateOne($item);
            $itemResult = $this->updateOneToService($item);
            if (!is_wp_error($itemResult)) {
               $success[] = $itemResult;
            } else {
@@ -236,9 +273,9 @@
    * @param array $item
    * @return array|WP_Error
    */
   protected function updateOne(array $item):array|WP_Error
   protected function updateOneToService(array $item):array|WP_Error
   {
      return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own updateOne method');
      return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own updateOneToService method');
   }
   /**
@@ -267,6 +304,62 @@
         ]
      ];
   }
   public function deleteOneToService(array $item):array|WP_Error
   {
      return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own deleteOneToService');
   }
   public function sendBatchDelete(array $items):array|WP_Error
   {
      return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchDelete');
   }
   public function deleteBatchToService(array $data):array
   {
      $type = $this->determineType($data);
      if (!$type) {
         $this->logError('createBatchToService', 'No expected keys in data', ['data' => $data]);
         return $this->noCreatedItems([]);
      }
      //Check if any submitted ids do not have an integration item ID
      $notCreated = array_filter($data['items'],
      function($ID) use ($type) {
         return empty($this->getServiceItemID($ID, $type));
      });
      if (!empty($notCreated)) {
         $this->logError('deleteBatchToService','Could not delete items',['items' => $notCreated]);
      }
      $created = array_filter($data['items'],
      function($ID) use ($type) {
         return !empty($this->getServiceItemID($ID, $type));
      });
      if ($this->hasBatchDelete) {
         $result = $this->sendBatchDelete($created);
      } else {
         $errors = $success = [];
         foreach ($created as $item) {
            try {
               $success[] = $this->deleteOneToService($item);
            } catch (Exception $e) {
               $errors[] = $e->getMessage();
            }
         }
         $result = [
            'outcome'   => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'),
            'result' => [
               'success'   => $success,
               'errors' => $errors
            ]
         ];
      }
      return $result;
   }
   /*****************************************************************
    * UTILITY
   *****************************************************************/
@@ -320,6 +413,22 @@
         return [];
      }
      $content = match ($type) {
         'post'   => get_post_type($itemID),
         'term'   => get_term($itemID)?->taxonomy,
         'user'   => jvbUserRole($itemID)
      };
      $registrar = Registrar::getInstance($content);
      if ($registrar) {
         $additional = $this->getAdditionalFields($registrar->getIntegration($this->service_name)->getContentType());
         $additional = array_combine(
            array_map(fn($k) => str_starts_with($k, '_'.$this->service_name) ? $k : "_{$this->service_name}_{$k}", array_keys($additional)),
            $additional
         );
         $additionalFields = array_merge($additionalFields, $additional);
      }
      $fields =  [
         'share_to_' . $this->service_name,
         '_keep_synced_' . $this->service_name,
@@ -332,406 +441,4 @@
      ];
      return $meta->getAll($fields);
   }
   /****************************************************************
    * POST SYNC
   ****************************************************************/
   public function addSavePost():void
   {
      if (!has_action('save_post', [$this, 'handleSavePost'])) {
         add_action('save_post', [$this, 'handleSavePost'], 20, 3);
      }
   }
   public function removeSavePost():void
   {
      remove_action('save_post', [$this, 'handleSavePost'], 20, 3);
   }
   public function handleSavePost(int $postID, WP_Post $post, bool $update):void
   {
      if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
      if (wp_is_post_revision($postID)) return;
      error_log('=== ['.$this->service_name.']::handleSavePost called');
      $postType = jvbNoBase($post->post_type);
      if (!in_array($postType, $this->syncPostTypes)) {
         error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true));
         return;
      }
      $registrar = Registrar::getInstance($postType);
      //Should not happen, as syncPostTypes is defined by Registrar instances
      if (!$registrar) {
         return;
      }
      $settings = $registrar->getIntegrationConfig($this->service_name);
      if (!$settings) {
         error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true));
         return;
      }
      $fields = $this->getSyncFields($postID, 'post');
      if (!$fields['share_to_'.$this->service_name]) {
         error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true));
         return;
      }
      $isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]);
      if ($post->post_status !== 'publish' && !$isShared) {
         error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.');
         return;
      }
      if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) {
         error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. ');
         return;
      }
      error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ====');
      $this->removeSavePost();
      $this->handleTheSavePost($postID, $post, $update, $settings);
      $this->addSavePost();
   }
   /**
    * Handle post save for syncing
    *
    * Override to implement custom sync logic when posts are saved.
    * Check the $settings array for post type specific configuration.
    *
    * @param int $postID The post ID
    * @param WP_Post $post The post object
    * @param bool $update Whether this is an update
    * @param array $settings Post type integration settings
    * @return void
    */
   protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings):void
   {
      error_log('==== ['.$this->title.']::handleTheSavePost ====');
      $this->queueOperation(self::$syncTo, [
         'posts'   => [$postID],
         'user'      => user_can($post->post_author, 'manage_options') ? null : $post->post_author
      ], [
         'priority' => 'high',
         'delay'    => 30,
      ]);
      Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued');
   }
   public function addDeletePost():void
   {
      if (!has_action('before_delete_post', [$this, 'handleDeletePost'])) {
         add_action('before_delete_post', [$this, 'handleDeletePost'], 20, 3);
      }
   }
   public function removeDeletePost():void
   {
      remove_action('before_delete_post', [$this, 'handleSavePost'], 20, 3);
   }
   public function handleDeletePost(int $postID):void
   {
      if (!$this->canSync['delete']) {
         return;
      }
      $postType = get_post_type($postID);
      if (!in_array(jvbNoBase($postType), $this->syncPostTypes)) {
         return;
      }
      $fields = $this->getSyncFields($postID, 'post');
      if (empty($fields["_{$this->service_name}_item_id"])) {
         return;
      }
      $post = get_post($postID);
      if (!$post) {
         return;
      }
      $userID = $this->determineUserID($post->post_author);
      if (!$userID) {
         return;
      }
      JVB()->queue()->add(
         self::$deleteFrom,
         $userID,
         [
            'fields' => [$postID => $fields],
            'service'   => $this->service_name,
            'type'      => 'post'
         ]
      );
   }
   /****************************************************************
    * TERM SYNC
    ****************************************************************/
   public function addSaveTerm():void
   {
      if (empty($this->syncTaxonomies)) {
         return;
      }
      if (!has_action('saved_term', [$this, 'handleSaveTerm'])) {
         add_action('saved_term', [$this, 'handleSaveTerm'], 20, 3);
      }
   }
   public function removeSaveTerm():void
   {
      if (empty($this->syncTaxonomies)) {
         return;
      }
      remove_action('saved_term', [$this, 'handleSaveTerm'], 20, 3);
   }
   protected function handleSaveTerm(int $termID, int $tt_id, string $taxonomy, bool $update, array $args):void
   {
      $tax = jvbNoBase($taxonomy);
      if (!in_array($tax, $this->syncTaxonomies)) {
         return;
      }
      $registrar = Registrar::getInstance($tax);
      if (!$registrar) {
         return;
      }
      $settings = $registrar->getIntegrationConfig($this->service_name);
      if (!$settings) {
         return;
      }
      $fields = $this->getSyncFields($termID, 'term');
      if (!$fields['share_to_'.$this->service_name]) {
         return;
      }
      $isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]);
      if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) {
         return;
      }
      $this->removeSaveTerm();
      $this->handleTheSaveTerm($termID, $update, $taxonomy, $settings);
      $this->addSaveTerm();
   }
   /**
    * @param int $termID
    * @param bool $update
    * @param array $settings
    * @return void
    */
   protected function handleTheSaveTerm(int $termID, bool $update, string $taxonomy, array $settings):void
   {
      error_log('==== ['.$this->title.']::handleTheSaveTerm ====');
      //TODO: Figure out some sort of permissions for if the user can share this term, particularly for content types
      //TODO: If this is a content type, it likely has its own integration. This is an edmonton.ink problem, so I'm offloading it for now
      $this->queueOperation(self::$syncTo, [
         'terms'   => [$termID],
         'user'      => get_current_user_id(),
      ], [
         'priority' => 'high',
         'delay'    => 30,
      ]);
      Meta::forTerm($termID)->set('_'.$this->service_name.'_sync_status', 'queued');
   }
   public function addDeleteTerm():void
   {
      if (!has_action('pre_delete_term', [$this, 'handleDeleteTerm'])) {
         add_action('pre_delete_term', [$this, 'handleDeleteTerm']);
      }
   }
   public function removeDeleteTerm():void
   {
      remove_action('pre_delete_term', [$this, 'handleDeleteTerm']);
   }
   public function handleDeleteTerm(int $termID, string $taxonomy):void
   {
      if (!$this->canSync['delete']) {
         return;
      }
      $tax = jvbNoBase($taxonomy);
      if (!in_array($tax, $this->syncTaxonomies)) {
         return;
      }
      $fields = $this->getSyncFields($termID, 'term');
      if (empty($fields["_{$this->service_name}_item_id"])) {
         return;
      }
      JVB()->queue()->add(
         self::$deleteFrom,
         0,
         [
            'fields' => [$termID => $fields],
            'service'   => $this->service_name,
            'type'      => 'term'
         ]
      );
   }
   /****************************************************************
    * USER SYNC
    ****************************************************************/
   public function addSaveUser():void
   {
      if (empty($this->syncUsers)) {
         return;
      }
      if (!has_action('profile_update', [$this, 'handleUpdateUser'])) {
         add_action('profile_update', [$this, 'handleUpdateUser'], 20, 1);
      }
      if (!has_action('user_register', [$this, 'handleUpdateUser'])) {
         add_action('user_register', [$this, 'handleUpdateUser'], 20, 1);
      }
   }
   public function removeSaveUser():void
   {
      if (empty($this->syncUsers)) {
         return;
      }
      remove_action('profile_update', [$this, 'handleUpdateUser'], 20);
      remove_action('user_register', [$this, 'handleUpdateUser'], 20);
   }
   protected function handleUpdateUser(int $userID):void
   {
      $user = $this->getOrCreateUser($userID);
      if (!$user) {
         return;
      }
      $fields = $this->getSyncFields($userID, 'user');
      if (!empty($fields["_{$this->service_name}_item_id"])) {
         return;
      }
      $this->removeSaveUser();
      $this->handleTheSaveUser($userID);
      $this->addSaveTerm();
   }
   protected function getOrCreateUser(int $userID):string|false
   {
      $user = get_userdata($userID);
      if (!$user || is_wp_error($user)) {
         return false;
      }
      $role = jvbUserRole($userID);
      if (!in_array(jvbNoBase($role), $this->syncUsers)) {
         return false;
      }
      $fields = $this->getSyncFields($userID, 'user');
      if (!empty($fields["_{$this->service_name}_item_id"])) {
         return $fields["_{$this->service_name}_item_id"];
      }
      $meta = Meta::forUser($userID);
      $serviceUserID = $this->searchServiceForUser($user->user_email??'');
      if ($serviceUserID) {
         $meta->set("{$this->service_name}_item_id", $serviceUserID);
         return $serviceUserID;
      }
      $created = $this->createServiceUser($userID, $fields);
      if ($created) {
         return $created;
      }
      return false;
   }
   public function searchServiceForUser(string $email):string|false
   {
      if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
         return false;
      }
      return $this->handleEmailSearch($email);
   }
   /**
    * Searches for existing user from sanitized email
    * @param string $email
    * @return string|false The found service's User ID or false on failure
    */
   abstract public function handleEmailSearch(string $email):string|false;
   public function createServiceUser(int $userID, array $fields):string|false
   {
      $checked = $this->validateUserFields($userID, $fields);
      $response = $this->handleCreateUser($checked);
      if ($response['success']) {
         $meta = Meta::forUser($userID);
         $meta->set("{$this->service_name}_item_id", $response['result']['id']);
      }
      return $response['success'] ? $response['customer']??false : false;
   }
   protected function validateUserFields(int $userID, array $fields):array|false
   {
      foreach ($fields as $f => $v) {
         $v = match ($f) {
            'email' => filter_var($v, FILTER_SANITIZE_EMAIL),
            'phone' => Sanitizer::sanitizePhone($v),
            default => sanitize_text_field($v),
         };
         if (empty($v) && in_array($f, $this->requiredUserFields())) {
            return false;
         }
         $fields[$f] = $v;
      }
      return $fields;
   }
   protected function requiredUserFields():array
   {
      return [];
   }
   /**
    * @param array $data User Data
    * @return array The created user ID or false on failure
    */
      abstract protected function handleCreateUser(array $data):array;
   /**
    * @param int $userID
    * @return void
    */
   protected function handleTheSaveUser(int $userID):void
   {
      error_log('==== ['.$this->title.']::handleTheSaveUser ====');
      //TODO: This is likely only for stuff like customers.
      //If we have multiple stores connected, we may have to queue operations to update every connection's customer account if they made an order with that store
      $role = jvbUserRole($userID);
      $registrar = Registrar::getInstance($role);
      if (!$registrar || !$registrar->hasIntegration($this->service_name)) {
         return;
      }
      $this->queueOperation(self::$syncTo, [
         'users'  => [$userID],
      ]);
   }
   public function addDeleteUser():void
   {
      if (!has_action('delete_user', [$this, 'handleDeleteTerm'])) {
         add_action('delete_user', [$this, 'handleDeleteTerm']);
      }
   }
   public function removeDeleteUser():void
   {
      remove_action('delete_user', [$this, 'handleDeleteTerm']);
   }
   public function handleDeleteUser(int $userID):void
   {
      if (!$this->canSync['delete']) {
         return;
      }
      $fields = $this->getSyncFields($userID, 'user');
      if (empty($fields["_{$this->service_name}_item_id"])) {
         return;
      }
      JVB()->queue()->add(
         self::$deleteFrom,
         0,
         [
            'fields' => [$userID => $fields],
            'service'   => $this->service_name,
            'type'      => 'user'
         ]
      );
   }
}