Jake Vanderwerf
5 days ago 0dfe1d8afafc59c4a5559c498342668d5a58d6ef
=Still working away at the Integrations overhaul
9 files added
6 files modified
1597 ■■■■ changed files
inc/integrations/APIEndpoint.php 95 ●●●●● patch | view | raw | blame | history
inc/integrations/Endpoint.php 88 ●●●●● patch | view | raw | blame | history
inc/integrations/EndpointField.php 57 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 3 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncFromPost.php 1 ●●●● patch | view | raw | blame | history
inc/integrations/SyncFromTerm.php 1 ●●●● patch | view | raw | blame | history
inc/integrations/SyncFromUser.php 1 ●●●● patch | view | raw | blame | history
inc/integrations/SyncHelpers.php 14 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncTo.php 531 ●●●● patch | view | raw | blame | history
inc/integrations/SyncToPost.php 140 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncToTerm.php 115 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncToUser.php 183 ●●●●● patch | view | raw | blame | history
inc/integrations/_Base.php 2 ●●● patch | view | raw | blame | history
inc/integrations/services/Square.php 359 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/AddIntegrationFields.php 7 ●●●●● patch | view | raw | blame | history
inc/integrations/APIEndpoint.php
New file
@@ -0,0 +1,95 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
class APIEndpoint
{
    protected string|false $create = false;
    protected string|false $update = false;
    protected string|false $delete = false;
    protected string|false $batchCreate = false;
    protected string|false $batchUpdate = false;
    protected string|false $batchDelete = false;
    protected string|false $batchImport = false;
    protected string|false $search = false;
    protected string|false $image = false;
    public function __construct() {
    }
    public function setCreate(string $create):void
    {
        $this->create = $create;
    }
    public function getCreate():string|false
    {
        return $this->create;
    }
    public function setUpdate(string $update):void
    {
        $this->update = $update;
    }
    public function getUpdate():string|false
    {
        return $this->update;
    }
    public function setDelete(string $delete):void
    {
        $this->delete = $delete;
    }
    public function getDelete():string|false
    {
        return $this->delete;
    }
    public function setSearch(string $search):void
    {
        $this->search = $search;
    }
    public function getSearch():string|false
    {
        return $this->search;
    }
    public function setImage(string $image):void
    {
        $this->image = $image;
    }
    public function getImage():string|false
    {
        return $this->image;
    }
    public function setBatchCreate(string $batchCreate):void
    {
        $this->batchCreate = $batchCreate;
    }
    public function getBatchCreate():string|false
    {
        return $this->batchCreate;
    }
    public function setBatchUpdate(string $batchUpdate):void
    {
        $this->batchUpdate = $batchUpdate;
    }
    public function getBatchUpdate():string|false
    {
        return $this->batchUpdate;
    }
    public function setBatchDelete(string $batchDelete):void
    {
        $this->batchDelete = $batchDelete;
    }
    public function getBatchDelete():string|false
    {
        return $this->batchDelete;
    }
    public function setImport(string $batchImport):void
    {
        $this->batchImport = $batchImport;
    }
    public function getBatchImport():string|false
    {
        return $this->batchImport;
    }
}
inc/integrations/Endpoint.php
New file
@@ -0,0 +1,88 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
class Endpoint {
    protected string $endpoint;
    protected array $fields;
    public function __construct(string $endpoint) {
        $this->endpoint = $endpoint;
    }
    public function getEndpoint():string
    {
        return $this->endpoint;
    }
    public function addIdempotencyKey(?string $key = null):EndpointField
    {
        if (is_null($key)) {
            $key = wp_generate_uuid4();
        }
        $field = new EndpointField('idempotency_key');
        $this->fields['idempotency_key'] = $field;
        return $field;
    }
    public function getFields():array
    {
        return $this->fields;
    }
    public function addField(string $slug, array $data = []):EndpointField
    {
        $slug = EndpointField::sanitizeSlug($slug);
        $field = new EndpointField($slug);
        if (!empty($data)) {
            foreach ($data as $key => $value) {
                if ($key === 'required' && $value !== false) {
                    $field->setRequired();
                } elseif (property_exists($field, $key)) {
                    $method = 'set'.implode('',array_map('ucfirst', explode('_',$key)));
                    if (method_exists($field, $method)) {
                        $field->$method($value);
                    }
                }
            }
        }
        $this->fields[$slug] = $field;
        return $field;
    }
    public function checkFields(array $fields):array|false
    {
        $fields = array_filter($fields, function ($f) use ($fields) {
            if (!array_key_exists($f, $this->fields)) {
                error_log('Endpoint '.$this->endpoint.'::checkFields removing field because it is not defined: '.print_r($fields[$f], true));
                return false;
            }
            return true;
        }, ARRAY_FILTER_USE_KEY);
        $required = array_filter($this->fields, function ($f) {
            return $f->isRequired();
        });
        $missing = [];
        foreach ($required as $field => $config) {
            if (!array_key_exists($field, $fields) || empty($fields[$field])) {
                error_log('Endpoint '.$this->endpoint.'::checkFields required field not found: '.$field);
                $missing[] = $field;
            }
        }
        if (!empty($missing)) {
            return false;
        }
        foreach ($fields as $field => $value) {
            $test = $this->fields[$field]->validate($value);
            if (!$test) {
                error_log('Endpoint '.$this->endpoint.'::checkFields could not validate value for '.$field.': '.print_r($value, true));
                unset($fields[$field]);
            }
        }
        return $fields;
    }
}
inc/integrations/EndpointField.php
New file
@@ -0,0 +1,57 @@
<?php
namespace JVBase\integrations;
use Closure;
use JVBase\meta\Meta;
use JVBase\meta\Validator;
if (!defined('ABSPATH')) {
    exit;
}
class EndpointField {
    protected string $slug;
    protected string $type = 'text';
    protected bool $required = false;
    protected Closure $validation;
    public function __construct(string $slug)
    {
        $this->slug = self::sanitizeSlug($slug);
    }
    public function setRequired():void
    {
        $this->required = true;
    }
    public function isRequired():bool
    {
        return $this->required;
    }
    public function setValidation(callable $validation):void
    {
        $this->validation = Closure::fromCallable($validation);
    }
    public function handleValidation(mixed $data):bool|null
    {
        if (is_callable($this->validation)) {
            return ($this->validation)($data);
        }
        return null;
    }
    public static function sanitizeSlug(string $slug):string
    {
        return str_replace('-','_', sanitize_title($slug));
    }
    public function validate(mixed $value):bool
    {
        $result = $this->handleValidation($value);
        if (is_null($result) && !empty($this->type)) {
            error_log('Handling validation with the Meta Validator.php class');
            Validator::validate($value, ['type' => $this->type]);
        }
        error_log('No validation set for '.$this->slug.' endpoint field');
        return true;
    }
}
inc/integrations/Integrations.php
@@ -29,6 +29,9 @@
     * These properties define how the integration connects to external services
     */
    protected string|array $apiBase = ''; // Base URL(s) for API endpoints. Array format: ['base' => '', 'auth' => '']
    /**
     * @var array<APIEndpoint> An array of endpoints objects, with optional create, update, delete, batch<x>, and batchImport
     */
    protected array $apiEndpoints = [];   // Valid endpoint paths for this service
    protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable
inc/integrations/SyncFromPost.php
New file
@@ -0,0 +1 @@
<?php
inc/integrations/SyncFromTerm.php
New file
@@ -0,0 +1 @@
<?php
inc/integrations/SyncFromUser.php
New file
@@ -0,0 +1 @@
<?php
inc/integrations/SyncHelpers.php
@@ -179,13 +179,17 @@
        $this->registerAdditionalQueueTypes($executor);
    }
    protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void
    {
        //Empty. Integration extensions can register additional operation types from here.
    }
    /**
     * @return void Sets the $this->contentTypes
     * Empty. Integration extensions can register additional operation types here
     * @param IntegrationExecutor $executor
     * @return void
     */
    protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void {}
    /**
     * @return void Sets the $this->contentTypes, which is an array of TYPE => [$fields]
     * Should probably set the endpoints here as well, which would be an array of [ TYPE => [ 'create' => '','update' =>'', 'delete'=>'', 'batchCreate'=>'','batchUpdate'=>'', 'batchDelete'=>''] endpoints
     */
    abstract protected function setContentTypes():void;
}
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'
            ]
        );
    }
}
inc/integrations/SyncToPost.php
New file
@@ -0,0 +1,140 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use WP_Post;
if (!defined('ABSPATH')) {
    exit;
}
trait SyncToPost
{
    use SyncTo, SyncHelpers, UserConnection;
    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'
            ]
        );
    }
}
inc/integrations/SyncToTerm.php
New file
@@ -0,0 +1,115 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
trait SyncToTerm
{
    use SyncTo, SyncHelpers, UserConnection;
    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'
            ]
        );
    }
}
inc/integrations/SyncToUser.php
New file
@@ -0,0 +1,183 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\Meta;
use JVBase\meta\Sanitizer;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
trait SyncToUser
{
    use SyncTo, SyncHelpers, UserConnection;
    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->addSaveUser();
    }
    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'] && !empty($response['data'])) {
            $meta = Meta::forUser($userID);
            $meta->set("{$this->service_name}_item_id", $response['data']['id']);
        }
        return $response['success'] ? $response['data']['id']??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'
            ]
        );
    }
}
inc/integrations/_Base.php
@@ -89,7 +89,7 @@
     */
    protected array $syncUsers = [];
    /**
     * @var array Integration's specific content types. Set by child classes' setContentTypes
     * @var array Integration's specific content types. An array of [ TYPE => [$fields] ] Set by child classes' setContentTypes
     */
    protected array $contentTypes = [];
inc/integrations/services/Square.php
@@ -3,6 +3,7 @@
namespace JVBase\integrations\services;
use Exception;
use JVBase\integrations\Admin;
use JVBase\integrations\APIEndpoint;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\OAuth;
@@ -35,7 +36,13 @@
        Webhooks;
    protected string $orderPostType = '_square_order';
    protected string $apiVersion = '2025-09-24';
    protected string $apiVersion = '2026-07-15';
    public bool $hasBatchCreate = true;
    public bool $hasBatchUpdate = true;
    public bool $hasBatchDelete = true;
    public array $apiEndpoints = [
    ];
    protected function __construct()
    {
@@ -157,8 +164,58 @@
    protected function initialize(): void
    {
        // TODO: Implement initialize() method.
        $this->defineEndpoints();
    }
        protected function defineEndpoints():void
        {
            $this->defineCustomerEndpoints();
            $this->defineProductEndpoints();
            $this->defineOrderEndpoints();
            $this->definePaymentEndpoints();
        }
            protected function defineCustomerEndpoints():void
            {
                $customer = new APIEndpoint();
                $customer->setCreate('/v2/customers');
                $customer->setBatchCreate('/v2/customers/bulk-create');
                $customer->setDelete('/v2/customers/');//add the item_id to the end
                $customer->setBatchDelete('/v2/customers/bulk-delete');
                $customer->setUpdate('/v2/customers/');//add the item_id to the end
                $customer->setBatchUpdate('/v2/customers/bulk-update');
                $customer->setSearch('/v2/customers/search');
                $customer->setImport('/v2/customers');
                $this->apiEndpoints['customer'] = $customer;
            }
            protected function defineProductEndpoints():void
            {
                $product = new APIEndpoint();
                $product->setSearch('/v2/catalog/search-catalog-items');
                $product->setImport('/v2/catalog/list');
                $product->setImage('/v2/catalog/images');
                $product->setCreate('/v2/catalog/object');
                $product->setBatchCreate('/v2/catalog/batch-upsert');
                $product->setUpdate('/v2/catalog/object');
                $product->setBatchUpdate('/v2/catalog/batch-upsert');
                $product->setDelete('/v2/catalog/object/');//add the item_id to the end
                $product->setBatchDelete('/v2/catalog/batch-delete');
                $this->apiEndpoints['product'] = $product;
            }
            protected function defineOrderEndpoints():void
            {
                $order = new APIEndpoint();
                $order->setCreate('/v2/orders');
                $order->setSearch('/v2/orders/search');
                $order->setUpdate('/v2/orders/'); //add order_id
                $this->apiEndpoints['order'] = $order;
            }
            protected function definePaymentEndpoints():void
            {
                $payment = new APIEndpoint();
                $payment->setCreate('/v2/payments');
                $this->apiEndpoints['payment'] = $payment;
            }
    protected function refreshAccessToken(OAuthCredentials $credentials): array
    {
@@ -227,8 +284,275 @@
    protected function setContentTypes(): void
    {
        // TODO: Implement setContentTypes() method.
        $base = $this->getBaseFields();
        $this->contentTypes = [
            'customer'              => $this->getCustomerFields(),
            'REGULAR'               => $base,
            'FOOD_AND_BEV'          => array_merge($base, $this->setFoodAndBevFields()),
            'APPOINTMENTS_SERVICE'  => array_merge($base, $this->setAppointmentServiceFields()),
            'EVENT'                 => array_merge($base, $this->setEventFields()),
            'GIFT_CARD'             => array_merge($base, $this->setGiftCardFields()),
        ];
    }
        protected function getCustomerFields():array
        {
            return [
                'address'   => [
                    'type'  => 'group',
                    'fields'    => [
                        'address_line_1'    => [
                            'type'  => 'text',
                            'label' => 'Address Line 1',
                            'hint'  => 'ex: 6551 111 St NW',
                            'required'  => true,
                            'section'   => 'address'
                        ],
                        'address_line_2'    => [
                            'type'  => 'text',
                            'label' => 'Address Line 2',
                            'hint'  => 'ex: Unit 2',
                            'section'   => 'address'
                        ],
                        'locality'  => [
                            'type'  => 'text',
                            'label'=> 'City',
                            'section'   => 'address',
                            'required'  => true,
                        ],
                        'administrative_district_level_1' => [
                            'type'  => 'text',
                            'label' => 'Province',
                            'hint' => 'The two-character code, example: AB',
                            'default'=> 'AB',
                            'section'   => 'address',
                            'required'  => true,
                        ],
                        'postal_code' => [
                            'type'  => 'text',
                            'label' => 'Postal Code',
                            'section'=> 'address'
                        ],
                        'country'   => [
                            'type'  => 'text',
                            'label' => 'Country Code',
                            'hint'  => 'The tw-character country code, example: CA',
                            'default'   => 'CA',
                            'section'   => 'address',
                            'required'  => true,
                        ],
                        'first_name'    => [
                            'type'  => 'text',
                            'label' => 'First Name (if different)',
                            'hint'  => 'Optional field to save the address to someone that isn\'t you'
                        ],
                        'last_name' => [
                            'type'  => 'text',
                            'label' => 'Last Name (if different)',
                            'hint'  => 'Optional field to save the address to someone that isn\'t you'
                        ]
                    ]
                ]
            ];
        }
        protected function getBaseFields():array
        {
            return [
                'price' => [
                    'type'        => 'number',
                    'label'       => 'Price',
                    'step'        => 0.01,
                    'max'         => 99999,
                    'description' => 'Price for this variation'
                ],
                'sku' => [
                    'type'        => 'text',
                    'label'       => 'SKU',
                    'description' => 'Stock keeping unit'
                ],
                'cart_quantity' => [
                    'type'  => 'number',
                    'label' => 'Quantity',
                    'hidden'    => true,
                ],
                'available_for_pickup' => [
                    'type'  => 'true_false',
                    'label' => 'Available for Pick Up',
                    'section'=> 'square-advanced'
                ],
                'available_online' => [
                    'type'  => 'true_false',
                    'label' => 'Available online',
                    'section'=> 'square-advanced'
                ],
                'product_variations' => [
                    'type'        => 'repeater',
                    'label'       => 'Product Variations',
                    'description' => 'Different versions of this product (sizes, colors, etc.)',
                    'add_label'   => 'Add Variation',
                    'section'     => 'variations',
                    'fields'      => $this->setVariationFields()
                ],
            ];
        }
            protected function setVariationFields(?string $type = null):array
            {
                $fields = [
                    'name' => [
                        'type'        => 'text',
                        'label'       => 'Variation Name',
                        'description' => 'e.g., "Small", "Large", "Red", etc.'
                    ],
                    'sku' => [
                        'type'        => 'text',
                        'label'       => 'SKU',
                        'description' => 'Stock keeping unit'
                    ],
                    'price' => [
                        'type'        => 'number',
                        'label'       => 'Price',
                        'step'        => 0.01,
                        'max'         => 99999,
                        'description' => 'Price for this variation'
                    ],
                    'track_inventory'   => [
                        'type'  => 'true_false',
                        'label' => 'Track Inventory',
                    ],
                    'item_id' => [
                        'type'        => 'text',
                        'label'       => 'Square Variation ID',
                        'description' => 'Square catalog ID for this variation',
                        'hidden'      => true
                    ],
                    'last_sync' => [
                        'type'   => 'datetime',
                        'label'  => 'Last Sync',
                        'hidden' => true
                    ]
                ];
                switch ($type) {
                    case 'FOOD_AND_BEV':
                        $fields['ingredients']  = [
                            'type'  => 'textarea',
                            'label' => 'Ingredients List',
                            'description'   => 'Separate ingredients with commas',
                        ];
                        $fields['preparation_time_duration']    = [
                            'type'  => 'number',
                            'label' => 'Preparation time (in minutes)',
                        ];
                        break;
                    case 'GIFT_CARD':
                        $fields['gift_card_type']   = [
                            'type'  => 'select',
                            'label' => 'Gift Card Type',
                            'options'   => [
                                'PHYSICAL'  => 'Physical',
                                'DIGITAL'   => 'Digital',
                            ],
                            'default'   => 'DIGITAL',
                        ];
                        break;
                    case 'APPOINTMENTS_SERVICE':
                        $fields['service_duration'] = [
                            'type'  => 'number',
                            'label' => 'Duration of Service in Minutes'
                        ];
                        $fields['available_for_booking']    = [
                            'type'  => 'true_false',
                            'label' => 'Available for Booking'
                        ];
                        break;
                }
                return array_combine(
                    array_map(fn($k) => '_square_' . $k, array_keys($fields)),
                    $fields
                );
            }
            protected function setFoodAndBevFields():array
            {
                return [
                    'ingredients'   => [
                        'type'  => 'textarea',
                        'label' => 'Ingredients',
                        'hint'  => 'A comma separated list of ingredients'
                    ],
                    'preparation_time_duration' => [
                        'label' => 'Preparation Time',
                        'type'  => 'number',
                        'hint'  => 'Preparation time in minutes'
                    ],
                    'product_variations' => [
                        'type'        => 'repeater',
                        'label'       => 'Product Variations',
                        'description' => 'Different versions of this product (sizes, colors, etc.)',
                        'add_label'   => 'Add Variation',
                        'section'     => 'variations',
                        'fields'      => $this->setVariationFields('FOOD_AND_BEV')
                    ],
                ];
                /* TODO: Set definitions for:
                    'dietary_preferences'   => [
                    ],
                    'calories_text' => [
                    ],
                */
            }
            protected function setAppointmentServiceFields():array
            {
                return [
                    'product_variations' => [
                        'type'        => 'repeater',
                        'label'       => 'Product Variations',
                        'description' => 'Different versions of this product (sizes, colors, etc.)',
                        'add_label'   => 'Add Variation',
                        'section'     => 'variations',
                        'fields'      => $this->setVariationFields('APPOINTMENTS_SERVICE')
                    ],
                ];
            }
            protected function setEventFields():array
            {
                return [
                    'venue_details' => [
                        'type'  => 'textarea',
                        'label' => 'Venue Details',
                    ],
                    'capacity'  => [
                        'type'  => 'number',
                        'label' => 'Capacity'
                    ],
                    'product_variations' => [
                        'type'        => 'repeater',
                        'label'       => 'Product Variations',
                        'description' => 'Different versions of this product (sizes, colors, etc.)',
                        'add_label'   => 'Add Variation',
                        'section'     => 'variations',
                        'fields'      => $this->setVariationFields('EVENT')
                    ],
                ];
            }
            protected function setGiftCardFields():array
            {
                return [
        //          'allowed_locations' => [
        //
        //          ],
                    'product_variations' => [
                        'type'        => 'repeater',
                        'label'       => 'Product Variations',
                        'description' => 'Different versions of this product (sizes, colors, etc.)',
                        'add_label'   => 'Add Variation',
                        'section'     => 'variations',
                        'fields'      => $this->setVariationFields('GIFT_CARD')
                    ],
                ];
            }
    protected function formatForService(int $itemID, string $type = 'post'): array
    {
@@ -255,21 +579,28 @@
                    ]
                ]
            );
            return $response['id'];
            return $response['data'];
        } catch (Exception $e) {
            return false;
        }
    }
        protected function handleEmailSearchResponse(WP_Error|array $response, string $method):array
        {
            $data = empty($response['customers'])
                ? false
                : $response['customers'][0]??false;
            return $this->response(true, 'Successfully got results.', $data);
        }
    protected function handleCreateUser(array $data): array
    {
        try {
            return $this->postRequest(
                sprintf('%s/v2/customers',$this->getBaseURL()),
                sprintf(
                    '%s%s',
                    $this->getBaseURL(),
                    $this->apiEndpoints['customer']['create']->getEndpoint()
                ),
                $data
            );
        } catch (Exception $e) {
@@ -278,7 +609,10 @@
    }
        protected function handleCreateUserResponse(WP_Error|array $response, string $method):array
        {
            $data = empty($response['customer'])
                ? false
                : $response['customer'];
            return $this->response(true, 'Successfully got results.', $data);
        }
    protected function validateUserFields(int $userID, array $fields):array|false
    {
@@ -616,5 +950,16 @@
        }
    }
    public function getAdditionalFields(?string $content_type = null):array
    {
        $this->setContentTypes();
        if (is_null($content_type) || !array_key_exists($content_type, $this->contentTypes)) {
            return [];
        }
        return $this->contentTypes[$content_type];
    }
}
inc/registrar/helpers/AddIntegrationFields.php
@@ -65,9 +65,6 @@
    public function getIntegrationFields():array
    {
        if ($this->registrar && $this->registrar->getType() === 'user' && $this->config->isCustomer()) {
            return JVB()->connect($this->service_name)->getAdditionalFields('customer');
        }
        $fields = [
            'share_to_'.$this->service_name => [
                'type'  => 'true_false',
@@ -136,6 +133,10 @@
        }
        $additional = JVB()->connect($this->service_name)->getAdditionalFields($this->config->getContentType());
        if (!empty($additional)) {
            $additional = array_combine(
                array_map(fn($k) => str_starts_with($k, '_'.$this->service_name) ? $k : "_{$this->service_name}_{$k}", array_keys($additional)),
                $additional
            );
            $fields = array_merge($fields, $additional);
        }
        return $fields;