=Refactor of Integrations.php to be a bit more useful with a suite of methods for create, update, delete back and forths for integrations where sharing is enabled. Also considering a single instance with a connect method to connect as the site (no user) vs connecting as an individual user - rather than recreating instances for every user.
| | |
| | | } |
| | | |
| | | |
| | | public function __construct() |
| | | private function __construct() |
| | | { |
| | | $this->customBlocks = new CustomBlocks(); |
| | | $this->managers = [ |
| | |
| | | |
| | | public function connect(string $service, ?int $userID = null): mixed |
| | | { |
| | | if ($userID) { |
| | | if (!$this->userCanConnect($service, $userID)) { |
| | | return null; |
| | | } |
| | | |
| | | if (!array_key_exists($service, $this->integrations)) { |
| | | return null; |
| | | } |
| | | return $this->serviceMap[$service]::getInstance($userID); |
| | | } |
| | | return (array_key_exists($service, $this->integrations)) ? $this->integrations[$service] : null; |
| | | // if ($userID) { |
| | | // if (!$this->userCanConnect($service, $userID)) { |
| | | // return null; |
| | | // } |
| | | // |
| | | // if (!array_key_exists($service, $this->integrations)) { |
| | | // return null; |
| | | // } |
| | | // |
| | | // return $this->serviceMap[$service]::getInstance($userID); |
| | | // } |
| | | return is_null($userID) ? |
| | | $this->integrations[$service]??null : |
| | | (array_key_exists($service, $this->integrations) ? $this->serviceMap[$service]::getInstance($userID) : null); |
| | | } |
| | | |
| | | public function userCanConnect(string $service, int $userID): bool |
| | |
| | | 'X-WP-Nonce': this.getNonce() |
| | | }; |
| | | |
| | | if (!isFormData && typeof options.body === 'object' && !Object.hasOwn(options.body, 'user')) { |
| | | options.body.user = this.getUser(); |
| | | } |
| | | // if (!isFormData && typeof options.body === 'object' && !Object.hasOwn(options.body, 'user')) { |
| | | // options.body.user = this.getUser(); |
| | | // } |
| | | |
| | | let obj = { |
| | | ...options, |
| | |
| | | if (data === null) { |
| | | return; |
| | | } |
| | | data.user = window.auth.getUser(); |
| | | // data.user = window.auth.getUser(); |
| | | |
| | | if (Object.hasOwn(data, 'term_name') && data['term_name'] === ''){ |
| | | delete data['term_name']; |
| | |
| | | |
| | | // Get the base content type |
| | | data.content = form.closest('dialog')?.dataset.content || this.content; |
| | | data.user = window.auth.getUser(); |
| | | // data.user = window.auth.getUser(); |
| | | |
| | | // Collect timeline entries |
| | | const timelineGroups = form.querySelectorAll('.upload-group'); |
| | |
| | | this.cancelServerSave(form.id); |
| | | |
| | | if (form.useQueue && window.jvbQueue) { |
| | | changes.user = window.auth.getUser(); |
| | | // changes.user = window.auth.getUser(); |
| | | window.jvbQueue.addToQueue({ |
| | | endpoint: form.options.endpoint, |
| | | data: changes, |
| | |
| | | |
| | | public static function has(string $property):bool |
| | | { |
| | | return property_exists(self::class, $property) && self::${$property} === true; |
| | | $inst = self::getInstance(); |
| | | return property_exists($inst, $property) && $inst::${$property} === true; |
| | | } |
| | | |
| | | public static function hasAny(array $properties):bool |
| | |
| | | 'post_type' => $type |
| | | ]); |
| | | } |
| | | |
| | |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | $post_type = $settings['fb_type'] ?? 'post'; |
| | | $sync_immediately = $settings['immediate'] ?? false; |
| | | |
| | | // Determine the Facebook post type |
| | | $fb_endpoint = self::FB_POST_TYPES[$post_type] ?? 'feed'; |
| | | |
| | | $operation_data = [ |
| | | 'post_id' => $postID, |
| | | 'fb_type' => $post_type, |
| | | 'endpoint' => $fb_endpoint, |
| | | 'page_id' => $this->page_id |
| | | ]; |
| | | |
| | | // Check for scheduled posting |
| | | $schedule_time = get_post_meta($postID, BASE . 'schedule_facebook', true); |
| | | $options = []; |
| | | |
| | | if ($schedule_time && strtotime($schedule_time) > time()) { |
| | | $options['scheduled'] = strtotime($schedule_time); |
| | | } elseif (!$sync_immediately) { |
| | | $options['delay'] = 300; // 5 minute delay for batching |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | } |
| | | |
| | | $operation = $update ? 'update_post' : 'create_post'; |
| | | $this->queueOperation($operation, $operation_data, $options); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'delay' => 60 |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array |
| | | { |
| | | try { |
| | | $fb = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this; |
| | | $fb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this; |
| | | switch ($operation->type) { |
| | | case 'facebook_create_post': |
| | | return $fb->createFacebookPost($data); |
| | |
| | | |
| | | public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void |
| | | { |
| | | $type = $settings['content_type']??'post'; //can be 'post', 'offer', 'event', 'hours', 'info', |
| | | $initial = $settings['initial']?? false; |
| | | $syncOnUpdate = $settings['update']??false; |
| | | if ($update && !$syncOnUpdate) { |
| | | return; |
| | | } |
| | | if (!$initial) { |
| | | |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | } |
| | | |
| | | $options = $data = []; |
| | | switch ($type) { |
| | | case 'menu_item': |
| | | $options = ['delay' => 60]; |
| | | break; |
| | | } |
| | | if (!user_can($post->post_author, 'manage_options')) { |
| | | $data['user'] = $post->post_author; |
| | | } |
| | | $data['post_id'] = $postID; |
| | | $operation = ($update) ? 'update_' : 'create_'; |
| | | |
| | | $this->queueOperation( |
| | | $operation.$type, |
| | | $data, |
| | | $options |
| | | ); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'delay' => 60 |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array |
| | | { |
| | | try { |
| | | $gmb = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this; |
| | | $gmb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this; |
| | | $gmb->ensureInitialized(); |
| | | switch ($operation->type) { |
| | | case 'gmb_create_menu_item': |
| | |
| | | ]; |
| | | } |
| | | |
| | | $gmb = new self($data['userID']??null); |
| | | $gmb = self::getInstance($data['userID']??null); |
| | | |
| | | // Build the complete menu structure |
| | | $menu_data = $gmb->collectMenu($menu_items); |
| | |
| | | namespace JVBase\integrations; |
| | | |
| | | use Exception; |
| | | use JVBase\meta\Meta; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use JVBase\ui\Checkout; |
| | |
| | | */ |
| | | class Helcim extends Integrations |
| | | { |
| | | protected static string $syncCustomer = 'helcim_sync_customer'; |
| | | protected string $service_name = 'helcim'; |
| | | protected string|array $apiBase = 'https://api.helcim.com/v2'; |
| | | |
| | |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | // Register queue operation types with IntegrationExecutor |
| | | $this->registerQueueTypes(); |
| | | |
| | | // Register webhook endpoint (handled by parent) |
| | | $this->registerWebhookEndpoint(); |
| | | } |
| | |
| | | } |
| | | |
| | | |
| | | protected function registerQueueTypes(): void |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register('helcim_sync_to', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 10, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_sync_from', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 10, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_delete_from', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 20, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_import', new TypeConfig( |
| | | $queue->registry()->register(self::$import, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register('helcim_sync_customer', new TypeConfig( |
| | | $queue->registry()->register(self::$syncCustomer, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | |
| | | |
| | | protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | $fields = $this->getSyncFields($postID, 'post', ['share_to_helcim', 'schedule_helcim']); |
| | | |
| | | if (empty($fields['share_to_helcim'])) { |
| | | return; |
| | | } |
| | | |
| | | // Uses IntegrationExecutor via TypeRegistry instead of FilteredExecutor |
| | | $this->queueOperation('sync_to', [ |
| | | error_log('==== [Helcim]::handleTheSavePost ===='); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID, |
| | | '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'); |
| | | } |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | $item_id = Meta::forPost($postID)->get("_{$this->service_name}_item_id"); |
| | | |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'queued'); |
| | | if (empty($item_id)) { |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$deleteFrom, [ |
| | | 'external_ids' => [$item_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | ]); |
| | | |
| | | } |
| | | |
| | | protected function handleImportFromHelcim(): array |
| | |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\meta\Meta; |
| | | use WP_Error; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | |
| | | */ |
| | | public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | // Check if post has featured image (Instagram requirement) |
| | | |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | } |
| | | if (!has_post_thumbnail($postID)) { |
| | | $this->logError('Cannot post to Instagram without featured image', [ |
| | | 'post_id' => $postID |
| | | ]); |
| | | return; |
| | | } |
| | | |
| | | // Queue the Instagram post creation |
| | | $this->queueOperation('create_post', [ |
| | | 'post_id' => $postID, |
| | | 'type' => get_post_type($postID) |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'priority' => 'normal', |
| | | 'delay' => 60 // Wait 1 minute to ensure all metadata is saved |
| | | 'delay' => 60 |
| | | ]); |
| | | |
| | | update_post_meta($postID, BASE . '_instagram_sync_status', 'queued'); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | use Exception; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\mergers\DefaultMerger; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\managers\ErrorHandler; |
| | |
| | | use JVBase\registrar\Registrar; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use WP_Query; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Term; |
| | | use WP_User; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | { |
| | | //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard |
| | | public static bool $hasExtraOptions = false; |
| | | public bool $hasBatchCreate = false; |
| | | public bool $hasBatchUpdate = false; |
| | | public bool $hasBatchDelete = false; |
| | | public bool $canCreateOnUpdate = false; |
| | | /** |
| | | * Queue types |
| | | * These types match with IntegrationExecutor |
| | | */ |
| | | protected static string $syncTo = 'sync_to'; |
| | | protected static string $deleteFrom = 'delete_from'; |
| | | protected static string $syncFrom = 'sync_from'; |
| | | protected static string $syncCustomer = 'sync_customer'; |
| | | protected static string $import = 'import'; |
| | | protected static string $syncTo; |
| | | protected static string $deleteFrom; |
| | | protected static string $syncFrom; |
| | | protected static string $syncCustomer; |
| | | protected static string $import; |
| | | /** |
| | | * API Configuration |
| | | * These properties define how the integration connects to external services |
| | |
| | | ]; |
| | | } |
| | | |
| | | self::$syncTo = $this->service_name.'_sync_to'; |
| | | self::$deleteFrom = $this->service_name.'_delete_from'; |
| | | self::$syncFrom = $this->service_name.'_sync_from'; |
| | | //TODO: What is the difference between sync_from and import? |
| | | self::$import = $this->service_name.'import_from'; |
| | | |
| | | add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4); |
| | | |
| | | |
| | | } |
| | | |
| | | protected function addFilters():bool |
| | |
| | | |
| | | protected function getPostTypes(): void |
| | | { |
| | | $key = BASE . $this->service_name . '_sync_post_types'; |
| | | $postTypes = get_option($key, false); |
| | | if (!$postTypes) { |
| | | $postTypes = []; |
| | | foreach (Registrar::getRegistered('post') as $registrar) { |
| | | $registrar = Registrar::getInstance($registrar); |
| | | if ($registrar->hasIntegration($this->service_name)) { |
| | | $postTypes[] = $registrar->getSlug(); |
| | | } |
| | | } |
| | | |
| | | update_option($key, $postTypes); |
| | | } |
| | | |
| | | $this->syncPostTypes = $postTypes; |
| | | $this->syncPostTypes = Registrar::withIntegration($this->service_name); |
| | | } |
| | | |
| | | protected function getTaxonomies():void |
| | |
| | | |
| | | protected function clearCache():array |
| | | { |
| | | $success = $this->cache->flush(); |
| | | $this->cache->flush(); |
| | | return [ |
| | | 'success' => $success, |
| | | 'success' => true, |
| | | ]; |
| | | } |
| | | |
| | |
| | | ):bool { |
| | | $queue = JVB()->queue(); |
| | | |
| | | // Add service prefix to operation type |
| | | $operation_type = strtolower($this->service_name) . '_' . $type; |
| | | |
| | | $queued = $queue->queueOperation( |
| | | $operation_type, |
| | | $type, |
| | | $this->userID ?? 0, |
| | | array_merge($data, ['service' => $this->service_name, 'user' => $this->userID??0]), |
| | | array_merge([ |
| | |
| | | */ |
| | | protected function registerHooks(): void |
| | | { |
| | | add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); |
| | | //Handled by IntegrationExecutor now |
| | | // add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); |
| | | if (is_null($this->userID)) { |
| | | add_action( 'wp_ajax_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] ); |
| | | add_action( 'wp_ajax_nopriv_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] ); |
| | | if ($this->handleWebhooks){ |
| | | } |
| | | |
| | | if ($this->handleWebhooks && is_null($this->userID)){ |
| | | $this->registerWebhookEndpoint(); |
| | | } |
| | | // Let child classes register additional hooks if needed |
| | | $this->registerAdditionalHooks(); |
| | | |
| | | if (!empty($this->syncPostTypes)) { |
| | | add_action('save_post', [$this, 'handleSavePost'], 20, 3); |
| | | if (!empty($this->syncPostTypes) && is_null($this->userID)) { |
| | | $this->addSavePost(); |
| | | add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3); |
| | | |
| | | if ($this->canSync['delete']) { |
| | | add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1); |
| | | } |
| | | } |
| | | if (!empty($this->syncTaxonomies)) { |
| | | if (!empty($this->syncTaxonomies) && is_null($this->userID)) { |
| | | add_action('saved_term', [$this, 'handleSaveTerm'], 20, 5); |
| | | if ($this->canSync['delete']) { |
| | | add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2); |
| | | } |
| | | } |
| | | |
| | | add_action('init', [$this, 'registerQueueTypes'], 10); |
| | | } |
| | | 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 registerQueueTypes():void |
| | | { |
| | | if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register(self::$syncTo, new TypeConfig( |
| | | mergeable: new DefaultMerger('items'), |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 50, |
| | | maxRetries: 3, |
| | | )); |
| | | |
| | | if ($this->canSync['delete']) { |
| | | $queue->registry()->register(self::$deleteFrom, new TypeConfig( |
| | | mergeable: new DefaultMerger('external_ids'), |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 200, |
| | | maxRetries: 2 |
| | | )); |
| | | } |
| | | |
| | | $queue->registry()->register(self::$syncFrom, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $this->registerAdditionalQueueTypes($executor); |
| | | } |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void |
| | | { |
| | | //Empty. Integration extensions can register additional operation types from here. |
| | | } |
| | | public function handleAjaxResponse() |
| | | { |
| | | |
| | |
| | | */ |
| | | public function handleSavePost(int $postID, WP_Post $post, bool $update): void |
| | | { |
| | | if (!is_null($this->userID)) { |
| | | return; |
| | | } |
| | | error_log('=== ['.$this->service_name.']::handleSavePost called'); |
| | | |
| | | if (!$postID || $postID === 0) { |
| | | return; |
| | | } |
| | |
| | | if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; |
| | | if (wp_is_post_revision($postID)) return; |
| | | |
| | | |
| | | if (empty($this->syncPostTypes) || !in_array(jvbNoBase($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); |
| | | if (!$registrar){ |
| | | return; |
| | | } |
| | | |
| | | if (empty($this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | |
| | | $settings = $registrar->hasIntegration($this->service_name)??null; |
| | | if (!$settings) { |
| | | error_log('Not handling save for '.$this->service_name.' because of no registrar settings'); |
| | | 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', ['schedule_'.$this->service_name]); |
| | | 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 = isset($fields["_{$this->service_name}_item_id"]); |
| | | if ($update && $isShared && !$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; |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ===='); |
| | | $this->removeSavePost(); |
| | | $this->handleTheSavePost($postID, $post, $update, $settings); |
| | | $this->addSavePost(); |
| | | } |
| | | |
| | | |
| | |
| | | { |
| | | return $this->icon; |
| | | } |
| | | |
| | | public function getServiceItemID(int $itemID, string $type = 'post'):string |
| | | { |
| | | return match($type) { |
| | | 'post' => Meta::forPost($itemID)->get("_{$this->service_name}_item_id"), |
| | | 'term','taxonomy' => Meta::forTerm($itemID)->get("_{$this->service_name}_item_id"), |
| | | 'user' => Meta::forUser($itemID)->get("_{$this->service_name}_item_id"), |
| | | default => '' |
| | | }; |
| | | } |
| | | |
| | | public function canBatchUpdate():bool |
| | | { |
| | | return $this->hasBatchUpdate; |
| | | } |
| | | public function canBatchCreate():bool |
| | | { |
| | | return $this->hasBatchCreate; |
| | | } |
| | | public function canBatchDelete():bool |
| | | { |
| | | return $this->hasBatchDelete; |
| | | } |
| | | |
| | | protected function updateItemStatus(int|array $items, string $status = 'pending', string $type = 'post'):void |
| | | { |
| | | if (!is_array($items)) { |
| | | $items = [$items]; |
| | | } |
| | | foreach ($items as $ID) { |
| | | match ($type) { |
| | | 'post' => Meta::forPost($ID)->set('_'.$this->service_name.'_sync_status', $status), |
| | | 'term' => Meta::forTerm($ID)->set('_'.$this->service_name.'_sync_status', $status), |
| | | 'user' => Meta::forUser($ID)->set('_'.$this->service_name.'_sync_status', $status), |
| | | default => false |
| | | }; |
| | | } |
| | | } |
| | | |
| | | public function updateBatchToService(array $data):array |
| | | { |
| | | $type = $data['type']??'post'; |
| | | |
| | | $newlyCreated = []; |
| | | if (!$this->canCreateOnUpdate) { |
| | | $created = array_filter($data['items'], function($ID) use ($type) { |
| | | return !empty($this->getServiceItemID($ID, $type)); |
| | | }); |
| | | |
| | | //Test to see if we have any that haven't been created yet. |
| | | //For some services, items may have to be created before they can be updated |
| | | if (count($created) !== count($data['items'])) { |
| | | $notCreated = array_filter($data['items'], function($ID) use ($type) { |
| | | return empty($this->getServiceItemID($ID, $type)); |
| | | }); |
| | | $newData = $data; |
| | | $newData['items'] = $notCreated; |
| | | $newlyCreated = $this->createBatchToService($newData); |
| | | } |
| | | |
| | | // If we don't have any that are created, just send the noUpdatedItems response, with any newly created items added |
| | | if (empty($created)) { |
| | | return $this->noUpdatedItems($newlyCreated); |
| | | } |
| | | $data['items'] = $created; |
| | | } |
| | | |
| | | |
| | | $items = $this->formatItems($data['items'], $type); |
| | | |
| | | if (empty($items)) { |
| | | return $this->noUpdatedItems($newlyCreated); |
| | | } |
| | | |
| | | if ($this->hasBatchUpdate) { |
| | | $response = $this->sendBatchUpdate($items); |
| | | if (!is_wp_error($response)) { |
| | | $result = $this->processBatchUpdateResponse($data, $response); |
| | | } else { |
| | | $this->logError('Batch update failed',[ |
| | | 'method' => 'updateBatchToService', |
| | | 'item_ids' => $data['items'], |
| | | 'error' => $response |
| | | ]); |
| | | $this->updateItemStatus($data['items'], 'error'); |
| | | |
| | | $result = [ |
| | | 'outcome' => 'failed_permanent', |
| | | 'result' => 'Could not update items' |
| | | ]; |
| | | } |
| | | } else { |
| | | $success = $errors = []; |
| | | //Does not have batch update, manually update each one |
| | | foreach ($items as $item) { |
| | | $itemResult = $this->updateOne($item); |
| | | if (!is_wp_error($itemResult)) { |
| | | $success[] = $itemResult; |
| | | } else { |
| | | $errors[] = $itemResult; |
| | | } |
| | | } |
| | | |
| | | $result = [ |
| | | 'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'), |
| | | 'result' => [ |
| | | 'success' => $success, |
| | | 'errors' => $errors |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return array_merge($result, $newlyCreated); |
| | | } |
| | | |
| | | /** |
| | | * To be implemented by integration extension. Updates a single item |
| | | * @param array $item |
| | | * @return array|WP_Error |
| | | */ |
| | | protected function updateOne(array $item):array|WP_Error |
| | | { |
| | | return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own updateOne method'); |
| | | } |
| | | |
| | | /** |
| | | * Overridden by child classes |
| | | * @param array $items |
| | | * @return array|WP_Error |
| | | */ |
| | | protected function sendBatchUpdate(array $items):array|WP_Error |
| | | { |
| | | return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchUpdate'); |
| | | } |
| | | /** |
| | | * Overridden by child classes |
| | | * @param array $items |
| | | * @return array|WP_Error |
| | | */ |
| | | protected function sendBatchCreate(array $items):array|WP_Error |
| | | { |
| | | return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchCreate'); |
| | | } |
| | | |
| | | /** |
| | | * To be implemented by extensions |
| | | * @param array $data |
| | | * @param array $response |
| | | * @return array |
| | | */ |
| | | protected function processBatchUpdateResponse(array $data, array $response):array |
| | | { |
| | | return [ |
| | | 'outcome' => 'failed', |
| | | 'result' => [ |
| | | 'message' => $this->service_name.' should implement processBatchUpdateResponse.' |
| | | ] |
| | | ]; |
| | | } |
| | | /** |
| | | * To be implemented by extensions |
| | | * @param array $data |
| | | * @param array $response |
| | | * @return array |
| | | */ |
| | | protected function processBatchCreateResponse(array $data, array $response):array |
| | | { |
| | | return [ |
| | | 'outcome' => 'failed', |
| | | 'result' => [ |
| | | 'message' => $this->service_name.' should implement processBatchCreateResponse.' |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Defaults to an array of formatted items. Can be overridden in child classes |
| | | * @param array $itemIDs |
| | | * @param string $type |
| | | * @return array |
| | | */ |
| | | protected function formatItems(array $itemIDs, string $type):array |
| | | { |
| | | $items = []; |
| | | foreach ($itemIDs as $ID) { |
| | | try { |
| | | $items[] = $this->formatForService($ID, $type); |
| | | } catch (Exception $e){ |
| | | $this->logError('Could not format Item for service',[ |
| | | 'itemID' => $ID, |
| | | 'type' => $type, |
| | | 'message' => $e->getMessage(), |
| | | ]); |
| | | } |
| | | } |
| | | return $items; |
| | | } |
| | | |
| | | /** |
| | | * Must be defined according to how each service needs it to be |
| | | * @param int $itemID |
| | | * @param string $type |
| | | * @return array |
| | | * @throws Exception |
| | | */ |
| | | protected function formatForService(int $itemID, string $type = 'post'):array |
| | | { |
| | | throw new Exception('formatForService must be implemented by child class'); |
| | | } |
| | | protected function noUpdatedItems(array $created):array |
| | | { |
| | | $result = [ |
| | | 'outcome' => 'success', |
| | | 'result' => [ |
| | | 'message' => 'No items to update', |
| | | 'updated' => [], |
| | | 'errors' => [], |
| | | ] |
| | | ]; |
| | | |
| | | if (!empty($created)) { |
| | | error_log('Result before: '.print_r($result, true)); |
| | | $result = array_merge($result, $created); |
| | | error_log('Result after merge: '.print_r($result, true)); |
| | | } |
| | | return $result; |
| | | } |
| | | |
| | | protected function noCreatedItems(array $updated):array |
| | | { |
| | | $result = [ |
| | | 'outcome' => 'success', |
| | | 'result' => [ |
| | | 'message' => 'No items to create', |
| | | 'created' => [], |
| | | 'errors' => [], |
| | | ] |
| | | ]; |
| | | if (!empty($updated)) { |
| | | $result = array_merge($result, $updated); |
| | | } |
| | | return $result; |
| | | } |
| | | |
| | | public function createBatchToService($data):array |
| | | { |
| | | $type = $data['type']??'post'; |
| | | |
| | | //Check if any of the submitted ids are already created |
| | | $created = array_filter($data['items'], |
| | | function($ID) use ($type) { |
| | | return !empty($this->getServiceItemID($ID, $type)); |
| | | }); |
| | | |
| | | $updated = []; |
| | | if (!empty($created)) { |
| | | $updateData = $data; |
| | | $updateData['items'] = $created; |
| | | $updated = $this->updateBatchToService($data); |
| | | |
| | | //remove any updated items from the original items to process |
| | | $data['items'] = array_filter($data['items'], function ($ID) use ($created) { |
| | | return !in_array($ID, $created); |
| | | }); |
| | | } |
| | | |
| | | $items = $this->formatItems($data['items'], $type); |
| | | if (empty($items)) { |
| | | 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',[ |
| | | 'method' => 'createBatchToService', |
| | | 'item_ids' => $data['items'], |
| | | 'error' => $response |
| | | ]); |
| | | $this->updateItemStatus($data['items'], 'error'); |
| | | |
| | | $result = [ |
| | | 'outcome' => 'failed_permanent', |
| | | 'result' => 'Could not update items' |
| | | ]; |
| | | } |
| | | } else { |
| | | $success = $errors = []; |
| | | foreach ($items as $item) { |
| | | $itemResult = $this->createOne($item); |
| | | if (!is_wp_error($itemResult)) { |
| | | $success[] = $itemResult; |
| | | } else { |
| | | $errors[] = $itemResult; |
| | | } |
| | | } |
| | | $result = [ |
| | | 'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'), |
| | | 'result' => [ |
| | | 'success' => $success, |
| | | 'errors' => $errors |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return array_merge($result, $updated); |
| | | } |
| | | |
| | | /** |
| | | * To be implemented by integration extension. Updates a single item |
| | | * @param array $item |
| | | * @return array|WP_Error |
| | | */ |
| | | protected function createOne(array $item):array|WP_Error |
| | | { |
| | | return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own createOne'); |
| | | } |
| | | |
| | | public function findWPItem(string $integrationID, ?string $type = null):array|false |
| | | { |
| | | if (empty($integrationID)) { |
| | | error_log('Integrations::findWPItem No Integration ID received.'); |
| | | return false; |
| | | } |
| | | $types = ['post', 'term', 'user']; |
| | | if (!is_null($type) && !in_array($type, $types)) { |
| | | error_log('Integrations::findWPItem invalid type sent: '.$type.'; defaulting to checking all'); |
| | | $type = null; |
| | | } |
| | | if (!is_null($type)) { |
| | | return $this->queryWPType($integrationID, $type); |
| | | } else { |
| | | foreach ($types as $type) { |
| | | $check = $this->queryWPType($integrationID, $type); |
| | | if ($check) { |
| | | return $check; |
| | | } |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * @param string $integrationID |
| | | * @param string|null $type |
| | | * @return array|false if success, an array of $ID and $type |
| | | */ |
| | | public function queryWPType(string $integrationID, ?string $type = null):array|false |
| | | { |
| | | if (!in_array($type, ['post', 'term', 'user'])) { |
| | | return $this->findWPItem($integrationID); |
| | | } |
| | | return match($type) { |
| | | 'post' => $this->findWPPost($integrationID), |
| | | 'term' => $this->findWPTerm($integrationID), |
| | | 'user' => $this->findWPUser($integrationID), |
| | | default => false |
| | | }; |
| | | } |
| | | public function findWPPost(string $integrationID):array|false |
| | | { |
| | | if (empty($integrationID)) { |
| | | return false; |
| | | } |
| | | $get = new WP_Query([ |
| | | 'meta_query' => [ |
| | | 'key' => BASE."_{$this->service_name}_item_id", |
| | | 'value' => $integrationID, |
| | | ], |
| | | 'fields' => 'ids', |
| | | 'posts_per_page' => 1, |
| | | ]); |
| | | return $get->have_posts() && !empty($get->posts) ? [$get->posts[0], 'post'] : false; |
| | | } |
| | | public function findWPTerm(string $integrationID):array|false |
| | | { |
| | | if (empty($integrationID)) { |
| | | return false; |
| | | } |
| | | $get = get_terms([ |
| | | 'meta_query' => [ |
| | | 'key' => BASE."_{$this->service_name}_item_id", |
| | | 'value' => $integrationID, |
| | | ], |
| | | 'fields' => 'ids', |
| | | 'hide_empty'=> true, |
| | | 'number' => 1 |
| | | ]); |
| | | return is_wp_error($get) || empty($get) ? false : [$get[0], 'term']; |
| | | } |
| | | public function findWPUser(string $integrationID):array|false |
| | | { |
| | | if (empty($integrationID)) { |
| | | return false; |
| | | } |
| | | $get = get_users([ |
| | | 'meta_query' => [ |
| | | 'key' => BASE."_{$this->service_name}_item_id", |
| | | 'value' => $integrationID, |
| | | ], |
| | | 'fields' => 'ids', |
| | | 'number' => 1, |
| | | ]); |
| | | return is_wp_error($get) || empty($get) ? false : [$get[0], 'user']; |
| | | } |
| | | |
| | | /********************************************************************************* |
| | | * Syncing WP items from Service |
| | | *********************************************************************************/ |
| | | protected function createItemFromService(array $itemData):bool |
| | | { |
| | | |
| | | } |
| | | } |
| | |
| | | */ |
| | | class Square extends Integrations |
| | | { |
| | | protected static string $syncCustomer = 'square_sync_customer'; |
| | | protected array $allowedContent = [ |
| | | 'REGULAR', |
| | | 'FOOD_AND_BEV', |
| | |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new static($userID); |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | |
| | | // Display properties |
| | | $this->title = 'Square'; |
| | | $this->icon = 'square-logo'; |
| | |
| | | 'name' => $variation['name'], |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => intval($variation['_square_price'] * 100), // Convert to cents |
| | | 'currency' => 'USD' |
| | | 'amount' => $this->formatPrice($variation['_square_price']??0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | |
| | | 'name' => 'Regular', |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => intval(($itemData['_square_price'] ?? 0) * 100), |
| | | 'currency' => 'USD' |
| | | 'amount' => $this->formatPrice($itemData['_square_price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | |
| | | return; |
| | | } |
| | | |
| | | add_action('wp_login', [$this, 'trackUserLogin'], 10, 2); |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | |
| | | add_filter('jvb_checkout_browse_text', function () { |
| | | return 'browse our menu'; |
| | | }); |
| | | // Register queue executor types |
| | | add_action('init', [$this, 'registerQueueTypes'], 10); |
| | | } |
| | | |
| | | /** |
| | |
| | | ]); |
| | | } |
| | | |
| | | public function registerQueueTypes(): void |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register(self::$syncTo, new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 50, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register(self::$deleteFrom, new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 200, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register(self::$syncFrom, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register(self::$syncCustomer, new TypeConfig( |
| | | executor: $executor, |
| | |
| | | */ |
| | | protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | error_log('==== [Square]::handleTheSavePost ===='); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user_id' => $this->userID, |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, |
| | | ]); |
| | | |
| | | update_post_meta($postID, BASE . '_square_sync_status', 'queued'); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | $square_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | $item_id = $this->getServiceItemID($postID); |
| | | |
| | | if ($square_id) { |
| | | if (empty($item_id)) { |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$deleteFrom, [ |
| | | 'external_ids' => [$square_id], |
| | | 'external_ids' => [$item_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | ]); |
| | | } |
| | | |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | private function processBatchSyncResponse(array $response, array $map, array &$success, array &$errors):void |
| | | { |
| | | error_log('==== SQUARE::processBatchSyncResponse ====='); |
| | | error_log('Full response: '.print_r($response, true)); |
| | | |
| | | // Handle successful objects |
| | | if (!empty($response['objects'])) { |
| | | foreach ($response['objects'] as $object) { |
| | |
| | | |
| | | // Get existing Square catalog ID if it exists |
| | | $existing_square_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | $registrar = Registrar::getInstance($post_type); |
| | | $product_type = 'FOOD_AND_BEV'; |
| | | if ($registrar) { |
| | | $conf = $registrar->getIntegration($this->service_name); |
| | | if ($conf) { |
| | | $product_type = $conf->getContentType(); |
| | | } |
| | | } |
| | | |
| | | // Build the base catalog object |
| | | $catalog_object = [ |
| | |
| | | 'item_data' => [ |
| | | 'name' => $post->post_title, |
| | | 'description' => wp_strip_all_tags($post->post_content), |
| | | 'product_type' => 'FOOD_AND_BEV', |
| | | 'product_type' => $product_type, |
| | | 'variations' => [], |
| | | 'is_taxable' => true, |
| | | ] |
| | |
| | | } |
| | | |
| | | // Add variations |
| | | $variations = $meta->get('product_variations'); |
| | | $variations = $meta->get('_square_product_variations'); |
| | | if (empty($variations)) { |
| | | // Create default variation if none exist |
| | | $price = floatval($meta->get('price') ?: 0); |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default', |
| | |
| | | 'ordinal' => 0, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => intval($price * 100), // Convert dollars to cents |
| | | 'currency' => 'CAD' |
| | | 'amount' => $this->formatPrice($meta->get('_square_price')), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } else { |
| | | $resetVariations = false; |
| | | foreach ($variations as $index => $variation) { |
| | | $existing_var_id = get_post_meta($postID, BASE . '_square_variation_' . $index . '_id', true); |
| | | $id = '#'.BASE.'menu_item_' . $postID . '_var_' . $index; |
| | | if (empty($variation['item_id'])) { |
| | | $resetVariations = true; |
| | | $variations[$index]['item_id'] = $id; |
| | | $variation['item_id'] = $id; |
| | | } |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $existing_var_id ?: '#'.BASE.'menu_item_' . $postID . '_var_' . $index, |
| | | 'id' => $variation['item_id'], |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation['name'] ?? 'Variation ' . ($index + 1), |
| | | 'ordinal' => $index, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => intval(floatval($variation['price'] ?? 0) * 100), |
| | | 'currency' => 'CAD' |
| | | 'amount' =>$this->formatPrice($variation['price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } |
| | | if ($resetVariations) { |
| | | $meta->set('_square_product_variations', $variations); |
| | | } |
| | | } |
| | | |
| | | // Add categories if they exist |
| | |
| | | return $catalog_object; |
| | | } |
| | | |
| | | /** |
| | | * Build variations from repeater field |
| | | */ |
| | | private function buildVariations(int $postID, array $values, string $post_type): array |
| | | { |
| | | $variations = []; |
| | | $product_variations = $values['product_variations'] ?? []; |
| | | |
| | | // Get variation field mapping |
| | | $variation_map = $this->getVariationMapping($post_type); |
| | | |
| | | // If we have repeater variations |
| | | if (!empty($product_variations) && is_array($product_variations)) { |
| | | foreach ($product_variations as $index => $variation_data) { |
| | | // Skip empty variations |
| | | if (empty($variation_data['name']) && empty($variation_data['_square_sku'])) { |
| | | continue; |
| | | } |
| | | |
| | | $variation = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#' . $post_type . '_' . $postID . '_var_' . $index, |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation_data['name'] ?? 'Variation ' . ($index + 1), |
| | | 'ordinal' => $index, |
| | | 'pricing_type' => 'FIXED_PRICING' |
| | | ] |
| | | ]; |
| | | |
| | | // Check for existing Square variation ID |
| | | $square_var_id = get_post_meta($postID, BASE . '_square_variation_' . $index . '_id', true); |
| | | if ($square_var_id) { |
| | | $variation['id'] = $square_var_id; |
| | | } |
| | | |
| | | // Map variation fields |
| | | foreach ($variation_map as $square_field => $wp_field) { |
| | | if (isset($variation_data[$wp_field])) { |
| | | switch ($square_field) { |
| | | case 'price': |
| | | $variation['item_variation_data']['price_money'] = [ |
| | | 'amount' => intval($variation_data[$wp_field] * 100), // Convert to cents |
| | | 'currency' => 'CAD' |
| | | ]; |
| | | break; |
| | | |
| | | case 'sku': |
| | | $variation['item_variation_data']['_square_sku'] = $variation_data[$wp_field]; |
| | | break; |
| | | |
| | | case 'track_inventory': |
| | | $variation['item_variation_data']['track_inventory'] = (bool)$variation_data[$wp_field]; |
| | | break; |
| | | |
| | | case 'service_duration': |
| | | if (!empty($variation_data[$wp_field])) { |
| | | $variation['item_variation_data']['service_data'] = [ |
| | | 'duration_minutes' => intval($variation_data[$wp_field]) |
| | | ]; |
| | | } |
| | | break; |
| | | |
| | | default: |
| | | $variation['item_variation_data'][$square_field] = $variation_data[$wp_field]; |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | |
| | | $variations[] = $variation; |
| | | } |
| | | } |
| | | |
| | | // If no variations exist, create a default one from base price |
| | | if (empty($variations) && !empty($values['price'])) { |
| | | $variations[] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#' . $post_type . '_' . $postID . '_var_default', |
| | | 'item_variation_data' => [ |
| | | 'name' => 'Regular', |
| | | 'ordinal' => 0, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => intval($values['price'] * 100), |
| | | 'currency' => 'CAD' |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return $variations; |
| | | } |
| | | |
| | | /** |
| | | * Get variation mapping for post type |
| | |
| | | ]; |
| | | } |
| | | |
| | | // Generate username from email |
| | | $username = sanitize_user(current(explode('@', $email))); |
| | | $username = $this->generateUniqueUsername($username); |
| | | |
| | | // Create user account without password (they'll set it via email) |
| | | $user_id = wp_create_user( |
| | | $username, |
| | | $email, |
| | | wp_generate_password(20, true, true), // Temporary random password |
| | | $email |
| | | ); |
| | |
| | | |
| | | // Set user role (assuming you have a customer role defined) |
| | | $user = new \WP_User($user_id); |
| | | $user->set_role(BASE.'foodie'); // Or whatever role |
| | | $user->set_role(BASE.'foodie'); |
| | | |
| | | // Generate password reset key |
| | | $reset_key = get_password_reset_key($user); |
| | |
| | | // Link to Square customer if exists |
| | | $square_customer_id = $this->getOrCreateSquareCustomer([ |
| | | 'email' => $email, |
| | | 'name' => $username |
| | | 'name' => $email |
| | | ]); |
| | | |
| | | if ($square_customer_id) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Generate unique username |
| | | */ |
| | | private function generateUniqueUsername(string $base): string |
| | | { |
| | | $username = $base; |
| | | $counter = 1; |
| | | |
| | | while (username_exists($username)) { |
| | | $username = $base . $counter; |
| | | $counter++; |
| | | } |
| | | |
| | | return $username; |
| | | } |
| | | |
| | | /** |
| | | * Send welcome email with password setup |
| | | */ |
| | | private function sendWelcomeEmail(\WP_User $user, string $reset_key): void |
| | |
| | | "%s\n\n" . |
| | | "Once you've set your password, you can:\n" . |
| | | "- View your order history\n" . |
| | | "- Save your favorite items\n" . |
| | | // "- Save your favorite items\n" . |
| | | "- Speed up checkout with saved payment methods\n\n" . |
| | | "If you didn't create this account, please ignore this email.\n\n" . |
| | | "Thanks,\n", |
| | |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Track user login for security |
| | | */ |
| | | public function trackUserLogin(string $user_login, \WP_User $user): void |
| | | { |
| | | // Check if user has Square integration |
| | | $role = jvbUserRole($user->ID); |
| | | $registrar = Registrar::getInstance($role); |
| | | if ($registrar) { |
| | | $config = $registrar->getIntegration($this->service_name); |
| | | if ($config->isCustomer()) { |
| | | $login_count = (int)get_user_meta($user->ID, BASE . '_square_login_count', true); |
| | | $login_count++; |
| | | |
| | | update_user_meta($user->ID, BASE . '_square_login_count', $login_count); |
| | | update_user_meta($user->ID, BASE . '_square_last_login', current_time('mysql')); |
| | | |
| | | // Check if password reset is needed |
| | | if ($login_count % self::PASSWORD_RESET_INTERVAL === 0) { |
| | | $this->schedulePasswordReset($user->ID); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Schedule password reset for security |
| | | */ |
| | | private function schedulePasswordReset(int $user_id): void |
| | | { |
| | | update_user_meta($user_id, BASE . '_square_password_reset_required', true); |
| | | |
| | | // Send notification |
| | | $user = get_user_by('ID', $user_id); |
| | | if ($user) { |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | '['.get_bloginfo('name').'] Security Code', |
| | | 'For your security, enter this code to continue accessing your account and saved payment methods.', |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * WEBHOOK HANDLING |
| | | ******************************************************************/ |
| | |
| | | }, $posts->posts); |
| | | |
| | | } |
| | | |
| | | public function formatPrice(string|float|int $priceValue):int |
| | | { |
| | | //Convert dollars to cents |
| | | return intval(floatval($priceValue) * 100); |
| | | } |
| | | } |
| | |
| | | |
| | | $op->errorMessage = $e->getMessage(); |
| | | |
| | | JVB()->error()->log( |
| | | '[Queue]:processOne', |
| | | $e->getMessage(), |
| | | [ |
| | | 'operation_id' => $op->id, |
| | | 'type' => $op->type, |
| | | 'user_id' => $op->userId, |
| | | 'retries' => $op->retries, |
| | | ], |
| | | $op->outcome === 'failed_permanent' ? 'critical' : 'warning' |
| | | ); |
| | | // JVB()->error()->log( |
| | | // '[Queue]:processOne', |
| | | // $e->getMessage(), |
| | | // [ |
| | | // 'operation_id' => $op->id, |
| | | // 'type' => $op->type, |
| | | // 'user_id' => $op->userId, |
| | | // 'retries' => $op->retries, |
| | | // ], |
| | | // $op->outcome === 'failed_permanent' ? 'critical' : 'warning' |
| | | // ); |
| | | } |
| | | |
| | | private function calculateBackoff(int $attempt): string |
| | |
| | | */ |
| | | private function parseOperationType(string $type): array |
| | | { |
| | | // Remove BASE prefix if present (e.g. 'jvb_helcim_sync_to' → 'helcim_sync_to') |
| | | $type = str_replace(BASE, '', $type); |
| | | |
| | | $pos = strpos($type, '_'); |
| | | if ($pos === false) { |
| | | throw new Exception("Invalid integration operation type: {$type}"); |
| | |
| | | /** |
| | | * Resolve integration instance, optionally for a specific user |
| | | */ |
| | | private function resolveIntegration(string $serviceName, int $userId): ?object |
| | | private function resolveIntegration(string $serviceName, ?int $userId = null): ?object |
| | | { |
| | | if (!isset($this->integrationCache[$serviceName])) { |
| | | $this->integrationCache[$serviceName] = JVB()->connect($serviceName); |
| | | if ($userId === 0) { |
| | | $userId = null; |
| | | } |
| | | |
| | | $integration = $this->integrationCache[$serviceName]; |
| | | |
| | | // If operation has a user context, re-instantiate for that user |
| | | if ($integration && $userId) { |
| | | $class = get_class($integration); |
| | | return new $class($userId); |
| | | } |
| | | |
| | | return $integration; |
| | | return JVB()->connect($serviceName, $userId); |
| | | } |
| | | |
| | | /***************************************************************** |
| | |
| | | return new Result(outcome: 'success', result: ['synced' => [], 'message' => 'No items to sync']); |
| | | } |
| | | |
| | | if ($integration->hasBatchUpdate()) { |
| | | $result = $integration->updateBatchToService($data['items']); |
| | | return new Result(outcome: $result['outcome'], result: $result['result']); |
| | | } |
| | | |
| | | foreach ($items as $postID) { |
| | | try { |
| | | $result = $integration->syncPostToService((int)$postID); |
| | |
| | | public static function withIntegration(string $integration, ?string $type = null):array |
| | | { |
| | | self::ensureInstanced(); |
| | | |
| | | if (!Site::has($integration)) { |
| | | Site::getInstance(); |
| | | if (!Site::hasIntegration($integration)) { |
| | | error_log('[Registrar]::withIntegration Integration not available to fetch: '.$integration); |
| | | return []; |
| | | } |
| | |
| | | if (!is_null($type) && $inst->type !== $type) { |
| | | return false; |
| | | } |
| | | return array_key_exists($integration, $this->integrationConfigs); |
| | | return array_key_exists($integration, $inst->integrationConfigs); |
| | | })); |
| | | } |
| | | |
| | |
| | | * @var bool whether this is a customer role, used if this is a user-based Registrar |
| | | */ |
| | | protected bool $isCustomer; |
| | | protected ?string $category = null; |
| | | |
| | | |
| | | |
| | |
| | | return $this->content_type; |
| | | } |
| | | |
| | | public function setCategory(string $category):void |
| | | { |
| | | $check = Registrar::getInstance($category); |
| | | if ($check) { |
| | | $this->category = $check->getBased(); |
| | | } |
| | | } |
| | | |
| | | public function setInitial(bool $set):self |
| | | { |
| | | $this->initial = $set; |
| | |
| | | 'type' => 'true_false', |
| | | 'label' => 'Share To '.$this->allowed[$this->service_name], |
| | | 'section' => 'sync', |
| | | 'default' => 1 |
| | | ] |
| | | ]; |
| | | if ($this->config->getUpdate()){ |
| | |
| | | 'field' => 'share_to_'.$this->service_name, |
| | | 'value' => 1, |
| | | 'operator' => '==' |
| | | ] |
| | | ], |
| | | 'default' => 1, |
| | | ]; |
| | | |
| | | $fields["_{$this->service_name}_item_id"] = [ |
| | |
| | | 'field' => 'share_to_'.$this->service_name, |
| | | 'operator' => '==', |
| | | 'value' => 1 |
| | | ] |
| | | ], |
| | | 'default' => 1, |
| | | ]; |
| | | } |
| | | $additional = JVB()->connect($this->service_name)->getAdditionalFields($this->config->getContentType()); |