Jake Vanderwerf
10 days ago de317675a8069b747cb253ba3e2b5dc394ca36ef
inc/integrations/SyncTo.php
@@ -2,14 +2,20 @@
namespace JVBase\integrations;
use Exception;
use JVBase\meta\Meta;
use JVBase\meta\Sanitizer;
use JVBase\registrar\Registrar;
use WP_Error;
use WP_Post;
use WP_Term;
use WP_User;
if (!defined('ABSPATH')) {
   exit;
}
trait SyncTo {
   use SyncHelpers;
   use SyncHelpers, UserConnection;
   /**
    * Must be defined according to how each service needs it to be
    * @param int $itemID
@@ -17,10 +23,7 @@
    * @return array
    * @throws Exception
    */
   protected function formatForService(int $itemID, string $type = 'post'):array
   {
      throw new Exception('formatForService must be implemented by child class');
   }
   abstract protected function formatForService(int $itemID, string $type = 'post'):array;
   /**
    * Defaults to an array of formatted items. Can be overridden in child classes
@@ -35,7 +38,7 @@
         try {
            $items[] = $this->formatForService($ID, $type);
         } catch (Exception $e){
            $this->logError('Could not format Item for service',[
            $this->logError('formatItems', 'Could not format Item for service',[
               'itemID' => $ID,
               'type'   => $type,
               'message'   => $e->getMessage(),
@@ -74,18 +77,12 @@
         return $this->noCreatedItems($updated);
      }
      $response = [
         'outcome'   => 'failed',
         'result' => [
            'message'   => 'No result'
         ]
      ];
      if ($this->hasBatchCreate) {
         $response = $this->sendBatchCreate($items);
         if (!is_wp_error($response)) {
            $result = $this->processBatchCreateResponse($data, $response);
         } else {
            $this->logError('Batch create failed',[
            $this->logError('createBatchToService','Batch create failed',[
               'method' => 'createBatchToService',
               'item_ids'  => $data['items'],
               'error'     => $response
@@ -198,7 +195,7 @@
         if (!is_wp_error($response)) {
            $result = $this->processBatchUpdateResponse($data, $response);
         } else {
            $this->logError('Batch update failed',[
            $this->logError('updateBatchToService','Batch update failed',[
               'method' => 'updateBatchToService',
               'item_ids'  => $data['items'],
               'error'     => $response
@@ -307,4 +304,434 @@
      }
      return $result;
   }
   /****************************************************************
    * UTILITY
   ****************************************************************/
   protected function getSyncFields(int $itemID, string $type, array $additionalFields = []):array
   {
      $meta = match ($type) {
         'post'   => Meta::forPost($itemID),
         'term'   => Meta::forTerm($itemID),
         'user'   => Meta::forUser($itemID),
         default => false
      };
      if (!$meta) {
         return [];
      }
      $fields =  [
         'share_to_' . $this->service_name,
         '_keep_synced_' . $this->service_name,
         "_{$this->service_name}_item_id",
         "_{$this->service_name}_last_sync",
         "_{$this->service_name}_shared_at",
         "_{$this->service_name}_sync_status",
         "_{$this->service_name}_scheduled_at",
         ... $additionalFields
      ];
      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'
         ]
      );
   }
}