Jake Vanderwerf
yesterday f4be611c51473359e6d41780f0313c446079e9d3
=Switched the /base/options.php to the same pattern as Site.php: a class based approached rather than a filter. Updated Meta.php to play along with the defined fields from there in Meta::forOptions. Had to change openingHoursSpecificationsTrait.php to not use the translater functions __('text','textdomain') for now, as we load before init.
15 files modified
663 ■■■■■ changed files
assets/js/concise/FrontendFavourites.js 72 ●●●● patch | view | raw | blame | history
base/Site.php 4 ●●●● patch | view | raw | blame | history
base/_setup.php 2 ●●● patch | view | raw | blame | history
base/options.php 138 ●●●●● patch | view | raw | blame | history
checks.php 32 ●●●● patch | view | raw | blame | history
inc/helpers/all.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/FavouritesManager.php 110 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php 12 ●●●● patch | view | raw | blame | history
inc/meta/Form.php 9 ●●●●● patch | view | raw | blame | history
inc/meta/Meta.php 7 ●●●● patch | view | raw | blame | history
inc/registrar/Fields.php 2 ●●● patch | view | raw | blame | history
inc/registrar/Registrar.php 12 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Integration.php 11 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FavouritesRoutes.php 244 ●●●●● patch | view | raw | blame | history
jvb.php 5 ●●●●● patch | view | raw | blame | history
assets/js/concise/FrontendFavourites.js
@@ -26,6 +26,11 @@
                }
            });
        this.queue = {
            add: new Map(),
            remove: new Map(),
        };
        this.store = store.favourites;
        // this.listStore = window.jvbStore.register(
@@ -71,39 +76,66 @@
        // Update button icon
        button.innerHTML = jvbSettings.icons[button.classList.contains('favourited') ? 'heart-filled' : 'heart'];
        window.debouncer.schedule(
            `favourite-${button.dataset.id}`,
            this.postFavourite({
                user: window.auth.getUser(),
                target_id: button.dataset.id,
                action: action,
                type: button.dataset.type
            }),
            100
        );
        this.checkQueue(action, {
            target_id: button.dataset.id,
            action: action,
            type: button.dataset.type
        });
        window.debouncer.schedule('favourites', this.postFavourites.bind(this), 200);
    }
    async postFavourite(args) {
        await window.auth.fetch(
    checkQueue(action, item) {
        switch (action) {
            case 'add':
                if (this.queue.remove.has(item.target_id)) {
                    this.queue.remove.delete(item.target_id);
                }
                this.queue.add.set(item.target_id, item);
                break;
            case 'remove':
                if (this.queue.add.has(item.target_id)) {
                    this.queue.add.delete(item.target_id);
                }
                this.queue.remove.set(item.target_id, item);
                break;
            default:
                return;
        }
    }
    async postFavourites() {
        console.log(this.queue,'Posting favourites');
        const response = await window.auth.fetch(
            `${jvbSettings.api}favourites`,
            {
                method: 'POST',
                headers: {
                    'X-Action-Nonce': window.auth.getNonce('favourites')
                },
                body: args
                body: {
                    user: window.auth.getUser(),
                    ... this.queue
                }
            }
        );
        if (args.action === 'add') {
            await this.store.setItem(button.dataset.id, {
                target_id: button.dataset.id,
                action: action,
                type: button.dataset.type,
            }).then(()=>{});
        if (response.ok) {
            console.log('Posted favourites - clearing queue');
            //Add or remove from store
            for (let item of this.queue.add.entries()) {
                await this.store.setItem(item.target_id,
                    item);
            }
            for (let item of this.queue.remove.entries()) {
                await this.store.delete(item.target_id);
            }
            //Clear the favourite queue
            this.queue.add.clear();
            this.queue.remove.clear();
        } else {
            await this.store.delete(button.dataset.id).then(()=>{});
            console.log(await response.json(), 'Something went wrong');
            window.debouncer.schedule('favourites', this.postFavourites.bind(this), 200);
        }
    }
base/Site.php
@@ -62,6 +62,10 @@
     */
    protected static bool $limit_hours = false;
    /**
     * @var bool $has_hours Whether this site has business hours
     */
    protected static bool $has_hours = false;
    /**
     * @var bool $enthusiast Whether to scaffold enthusiasts (users that can interact with and save favourites)
     */
    protected static bool $enthusiast = false;
base/_setup.php
@@ -16,7 +16,7 @@
//require(JVB_DIR.'/base/users.php');
require(JVB_DIR.'/base/Login.php');
require(JVB_DIR.'/base/Membership.php');
require(JVB_DIR.'/base/options.php');
require(JVB_DIR.'/base/Options.php');
require(JVB_DIR.'/base/SchemaHelper.php');
$base = apply_filters('jvb_base', 'jvb_');
base/options.php
@@ -1,7 +1,145 @@
<?php
namespace JVBase\base;
use JVBase\managers\Cache;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\registrar\Fields;
/**
 * Custom options for the site can be set here. They will need to be handled by the child plugin, but you can make use of the Meta.php this way
 */
$options = apply_filters('jvb_options', []);
define('JVB_OPTIONS', $options);
class Options {
    protected static Options $instance;
    protected array $resetFields = [];
    protected string $resetInterval = 'daily';
    protected Fields $fields;
    protected array $sections = [];
    protected static array $values = [];
    private function __construct(){
        $this->setFields();
        if (!empty($this->resetFields)) {
            add_action('jvbDailyReset', [$this, 'resetOptions']);
        }
    }
    public static function getInstance():Options {
        if (!isset(self::$instance)) {
            self::$instance = new self();
            do_action('jvb_define_options');
        }
        return self::$instance;
    }
    public function setFields():void
    {
        $this->fields = new Fields('options');
        if (Site::has('limit_hours')) {
            $this->fields->addField(
                'today_hours',
                [
                    'type'  => 'group',
                    'label' => 'Today\'s Hours',
                    'fields'    => [
                        'time_start'    => [
                            'type'  => 'time',
                            'label' => 'Open',
                        ],
                        'time_end'  => [
                            'type'  => 'time',
                            'label' => 'Closed',
                        ]
                    ]
                ]
            );
            $this->fields->addField(
                'open_to_public',
                [
                    'type'  => 'true_false',
                    'label' => 'Open to Public?'
                ]
            );
            if (Site::hasAnyIntegration(['facebook','instagram'])) {
                $this->fields->addField(
                    'post_to_social',
                    [
                        'type'  => 'true_false',
                        'label' => 'Post to Socials?',
                    ]
                );
            }
        }
        if (Site::hasIntegration('gmb') || Site::has('hours')) {
            $this->fields->addCommon('hours');
        }
    }
    public function fields():Fields
    {
        return $this->fields;
    }
    public function resetOptions():void
    {
        if (empty($this->resetFields)) {
            return;
        }
        foreach ($this->resetFields as $field) {
            self::delete($field);
        }
        Cache::for('options')->flush();
    }
    public static function get(string $fieldName):mixed
    {
        if (!array_key_exists($fieldName, self::$values)) {
            $meta = Meta::forOptions();
            self::$values[$fieldName] = $meta->get($fieldName);
        }
        return self::$values[$fieldName];
    }
    public static function delete(string $fieldName):void
    {
        if (array_key_exists($fieldName, self::$values)) {
            unset(self::$values[$fieldName]);
        }
        $meta = Meta::forOptions();
        $meta->delete($fieldName);
    }
    public static function set(string $fieldName, mixed $value):void
    {
        $meta = Meta::forOptions();
        $meta->set($fieldName, $value);
        self::$values = $value;
    }
    public static function render(array $fieldNames, array $options = [], bool $output = false):string
    {
        $meta = Meta::forOptions();
        $result = Form::renderFormFrom(
            $meta,
            'options',
            $options,
            $fieldNames
        );
        if ($output) {
            echo $result;
        }
        return $result;
    }
    public function getFields():array
    {
        return array_map(function ($field) {
            return $field->getConfig();
        },
            $this->fields->getFields()
        );
    }
}
checks.php
@@ -1,5 +1,6 @@
<?php
use JVBase\base\Options;
use JVBase\base\Site;
use JVBase\managers\Cache;
use JVBase\registrar\Registrar;
@@ -18,22 +19,21 @@
}
//function jvbIsOpen():bool
//{
//
//  if (!jvbCheck('limit_hours', JVB_SITE)) {
//      return true;
//  }
//  if (get_option(BASE.'open_to_public') !== '1') {
//      return false;
//  }
//  //Check if today_hours is set
//  if (get_option(BASE.'today_hours')) {
//      return jvbIsTimeBetween();
//  }
//  //Default to the stored settings
//  return jvbIsCurrentlyOpen();
//}
function jvbIsOpen():bool
{
    if (!Site::has('limit_hours')) {
        return true;
    }
    if (Options::get('open_to_public') !== 1) {
        error_log('Not open to public');
        return false;
    }
    if (Options::get('today_hours')) {
        return jvbIsTimeBetween();
    }
    return jvbIsCurrentlyOpen();
}
function jvbTermHasPosts(int $termID, string $taxonomy):bool
inc/helpers/all.php
@@ -1,5 +1,7 @@
<?php
use JVBase\base\Site;
if (!defined('ABSPATH')) {
    exit;
}
@@ -86,3 +88,4 @@
        'post_type'     => $type
    ]);
}
inc/managers/FavouritesManager.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
use Exception;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
@@ -18,8 +19,16 @@
    public function __construct()
    {
        $this->defineTables();
        $this->registerHooks();
    }
    protected function registerHooks():void
    {
        add_action('before_delete_post', [$this, 'cleanupPostFavourites']);
        add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3);
        add_action('jvbUserRegistered', [$this, 'maybeAcceptListInvite'], 10, 3);
        add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']);
    }
    protected function defineTables():void
    {
        $this->defineFavouriteTable();
@@ -126,6 +135,7 @@
                'list_id'       => 'bigint(20) unsigned NOT NULL',
                'user_id'       => "{$table->getUserIDType()} NOT NULL",
                'email'         => 'varchar(255) NOT NULL',
                'invite_token'  => 'varchar(255) NOT NULL',
                'permission'    => "ENUM('view', 'edit') NOT NULL DEFAULT 'view'",
                'status'        => "ENUM('pending', 'accepted', 'rejected', 'revoked') NOT NULL DEFAULT 'pending'",
                'created_at'    => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
@@ -372,6 +382,7 @@
            } elseif ($newUser) {
                $email = sanitize_email($newUser['email']);
                $name = sanitize_text_field($newUser['name']);
                $args['invite_token'] = wp_generate_password(32, false);
                $args['email'] = $email;
                //TODO: Magic Link setup
            }
@@ -570,6 +581,59 @@
                    'shared_users'  => $sharedWith
                ];
            }
    public function getFavouriteCounts(int $user_id): array
    {
        $results = $this->favourites->queryResults(
            "SELECT type, COUNT(*) as count FROM {table} WHERE user_id = %d GROUP BY type",
            [$user_id]
        );
        $counts = [];
        foreach ($results as $row) {
            $type = str_replace(BASE, '', $row->type);
            $counts[$type] = (int) $row->count;
        }
        $defaults = array_fill_keys(
            array_map(fn($t) => str_replace(BASE, '', $t), Registrar::withFeature('favouritable')),
            0
        );
        return array_merge($defaults, $counts);
    }
    public function cleanupOrphanedFavourites(): bool
    {
        global $wpdb;
        // Posts - no FK possible since target_id is generic
        $this->favourites->query(
            "DELETE f FROM {table} f
         LEFT JOIN {$wpdb->posts} p ON f.target_id = p.ID
         WHERE f.type IN (
             SELECT CONCAT('" . BASE . "', post_type)
             FROM {$wpdb->posts} GROUP BY post_type
         )
         AND p.ID IS NULL"
        );
        // Terms - same reason
        $this->favourites->query(
            "DELETE f FROM {table} f
         LEFT JOIN {$wpdb->term_taxonomy} tt ON f.target_id = tt.term_id
         AND f.type = CONCAT('" . BASE . "', tt.taxonomy)
         WHERE tt.term_id IS NULL
         AND f.type != CONCAT('" . BASE . "', 'user')"
        );
        $this->listItems->query(
            "DELETE li FROM {table} li
         LEFT JOIN {$wpdb->posts} p ON li.target_id = p.ID
         LEFT JOIN {$wpdb->term_taxonomy} tt ON li.target_id = tt.term_id
         WHERE p.ID IS NULL AND tt.term_id IS NULL"
        );
        return true;
    }
    /***************************************************************
     * UTILITY METHODS
     **************************************************************/
@@ -682,4 +746,50 @@
        unset($notes[$index]);
        return array_values($notes); //reindexed array
    }
    public function cleanupPostFavourites(int $post_id):void
    {
        try {
            $type = get_post_type($post_id);
            if (!$type) return;
            $this->favourites->where([
                'type'  => $type,
                'target_id' => $post_id
            ])->deleteResults();
            $this->listItems->where([
                'item_type' => $type,
                'item_id'   => $post_id
            ])->deleteResults();
        } catch (Exception $e) {
            JVB()->error()->log('cleanupPostFavourites', $e->getMessage(), [
                'post_id' => $post_id
            ]);
        }
    }
    public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy):void
    {
        try {
            $registrar = Registrar::getInstance($taxonomy);
            if (!$registrar || !$registrar->hasFeature('favouritable')) {
                return;
            }
            $this->favourites->where([
                'type'  => $taxonomy,
                'target_id' => $term_id,
            ])->deleteResults();
            $this->listItems->where([
                'item_type' => $taxonomy,
                'item_id'   => $term_id
            ])->deleteResults();
        } catch (Exception $e) {
            JVB()->error()->log('cleanupTermFavourites', $e->getMessage(), [
                'term_id' => $term_id,
                'taxonomy'=> $taxonomy
            ]);
        }
    }
}
inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php
@@ -47,11 +47,11 @@
    {
        $fields->addField('openingHours', [
            'type'  => 'repeater',
            'label' => __('Opening Hours', 'jvb'),
            'label' => 'Opening Hours',
            'fields'    => [
                'dayOfWeek' => [
                    'type'      => 'set',
                    'label'     => __('Day(s) of Week', 'jvb'),
                    'label'     => 'Day(s) of Week',
                    'options'   => [
                        'Mo'    => 'Monday',
                        'Tu'    => 'Tuesday',
@@ -65,23 +65,23 @@
                ],
                'opens' => [
                    'type'  => 'time',
                    'label' => __('Opens at', 'jvb'),
                    'label' => 'Opens at',
                    'required'  => true
                ],
                'closes'    => [
                    'type'  => 'time',
                    'label' => __('Closes at', 'jvb'),
                    'label' => 'Closes at',
                    'required'  => true
                ]
            ]
        ]);
        $fields->addField('by_appointment', [
            'type'  => 'true_false',
            'label' => __('By Appointment Only', 'jvb'),
            'label' => 'By Appointment Only',
        ]);
        $fields->addField('allow_walkins', [
            'type'  => 'true_false',
            'label' => __('Walk Ins Welcome', 'jvb')
            'label' => 'Walk Ins Welcome'
        ]);
    }
    public function formatOpeningHoursSpecificationField(Meta $meta):void
inc/meta/Form.php
@@ -53,7 +53,7 @@
    /**
     * Render complete form from Meta instance
     */
    public static function renderFormFrom(Meta $meta, string $endpoint, array $options = []): string
    public static function renderFormFrom(Meta $meta, string $endpoint, array $options = [], array $fields = []): string
    {
        $id = $options['form-id'] ?? $endpoint;
        $classes = isset($options['classes']) ? ' class="' . implode(' ', $options['classes']) . '"' : '';
@@ -70,9 +70,10 @@
                $output .= '<p>' . esc_html($d) . '</p>';
            }
        }
        foreach ($meta->configs() as $name => $config) {
            $output .= static::render($name, $meta->get($name), $config);
        $allFields = $meta->getAll($fields);
        foreach ($allFields as $name => $value) {
            $config = $meta->config($name);
            $output .= static::render($name, $value, $config);
        }
        if (!empty($options['submit'])) {
inc/meta/Meta.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\meta;
use JVBase\base\Options;
use JVBase\registrar\Registrar;
use WP_Post;
use WP_Term;
@@ -75,7 +76,7 @@
    /**
     * Create Meta instance for options
     */
    public static function forOptions(?string $baseKey = 'ajv'): self
    public static function forOptions(?string $baseKey = BASE): self
    {
        if (array_key_exists($baseKey, self::$instances['options'])) {
            return self::$instances['options'][$baseKey];
@@ -118,6 +119,10 @@
        $registrar = !is_null($this->slug) ? Registrar::getInstance($this->slug) : false;
        $fields = $registrar ? $registrar->getFields() : [];
        if ($this->type == 'options') {
            $options = Options::getInstance();
            $fields = $options->getFields();
        }
        $meta = match($type) {
            'post'  => get_post_meta($id),
            'term'  => get_term_meta($id),
inc/registrar/Fields.php
@@ -17,7 +17,7 @@
class Fields {
    protected array $fields;
    private Registrar $registrar;
    private ?Registrar $registrar = null;
    public function __construct(?string $type = null, ?Registrar $registrar = null) {
        $this->registrar = $registrar;
inc/registrar/Registrar.php
@@ -14,6 +14,7 @@
use JVBase\registrar\config\Directory;
use JVBase\registrar\config\Feed;
use JVBase\registrar\config\Integration;
use JVBase\registrar\config\Register;
use JVBase\registrar\config\Section;
use JVBase\registrar\config\SEO;
use JVBase\registrar\helpers\AddIntegrationFields;
@@ -212,6 +213,7 @@
    protected Dashboard $dashboard;
    protected Directory|false $directory;
    protected Feed|false $feed;
    protected Register|false $login;
//  protected Management $management;
//  protected Responses $responses;
    protected ?SEO $seo = null;
@@ -653,7 +655,7 @@
    public function config(string $config):mixed
    {
        $allowed = ['breadcrumbs','calendar','dashboard','directory','feed','management','has_responses','seo','trackchanges','verification'];
        $allowed = ['breadcrumbs','calendar','register','login','dashboard','directory','feed','management','has_responses','seo','trackchanges','verification'];
        if (!in_array(strtolower($config), $allowed)) {
            error_log('Invalid config requested from Registrar: '.$config);
            return [];
@@ -662,6 +664,7 @@
            'breadcrumbs'   => $this->getBreadcrumbs(),
            'dashboard'     => $this->getDashboard(),
            'directory'     => $this->getDirectory(),
            'register','login' => $this->getLogin(),
            'feed'          => $this->getFeed(),
            'management'    => $this->getManagement(),
            'has_responses' => $this->getResponses(),
@@ -670,6 +673,13 @@
            'verification'  => $this->getVerification()
        };
    }
        protected function getLogin():Register|false
        {
            if (!isset($this->login)) {
                $this->login = new Register();
            }
            return $this->login;
        }
        protected function getBreadcrumbs():Breadcrumbs
        {
            if (!isset($this->breadcrumbs)) {
inc/registrar/config/Integration.php
@@ -13,7 +13,7 @@
     * @var string Must match as defined in the JVBase\integrations namespace
     */
    protected string $service_name;
    protected string $content_type;
    protected ?string $content_type = null;
    /**
     * @var bool Whether to send to the integration on publish
     */
@@ -45,11 +45,14 @@
    }
    /**
     * @param string $content must match what integration expects
     * @param ?string $content must match what integration expects
     * @return self
     */
    public function setContentType(string $content):self
    public function setContentType(?string $content):self
    {
        if (is_null($content)) {
            return $this;
        }
        $connection = JVB()->connect($this->service_name);
        if (!$connection){
            error_log('[Integration]::setContentType Service is not setup. '.$this->service_name);
@@ -64,7 +67,7 @@
        return $this;
    }
    public function getContentType():string
    public function getContentType():?string
    {
        return $this->content_type;
    }
inc/rest/routes/FavouritesRoutes.php
@@ -46,13 +46,6 @@
        $this->lists = CustomTable::for('favourites_lists');
        $this->listItems = CustomTable::for('favourites_list_items');
        $this->listShares = CustomTable::for('favourites_list_shares');
        // Register hooks
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
        add_action('before_delete_post', [$this, 'cleanupPostFavourites']);
        add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3);
        add_action('jvbUserRegistered', [$this, 'maybeAcceptListInvite'], 10, 3);
        add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']);
    }
    public function registerRoutes(): void
@@ -215,44 +208,14 @@
     */
    public function getFavouriteCounts(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = absint($request->get_param('user'));
        if (!$this->userCheck($user_id)) {
            return $this->unauthorized();
        }
        $key = "counts_{$user_id}";
        $counts = $this->cache->remember($key, function() use ($user_id) {
            try {
                // Get counts grouped by type using raw query
                $results = $this->favourites->queryResults(
                    "SELECT type, COUNT(*) as count FROM {table} WHERE user_id = %d GROUP BY type",
                    [$user_id],
                    OBJECT_K
                );
                $all_counts = array_fill_keys(
                    array_map(fn($type) => str_replace(BASE, '', $type), array_keys($this->valid_types)),
                    0
                );
                foreach ($results as $type => $data) {
                    $type_key = str_replace(BASE, '', $type);
                    $all_counts[$type_key] = (int)$data->count;
                }
                return $all_counts;
            } catch (Exception $e) {
                $this->logError('getFavouriteCounts', [
                    'error' => $e->getMessage(),
                    'user_id' => $user_id
                ]);
                return array_fill_keys(array_keys($this->valid_types), 0);
            }
        });
        $counts = JVB()->favourites()->getFavouriteCounts($user_id);
        return Response::success(['counts' => $counts]);
    }
@@ -560,105 +523,6 @@
    }
    /**
     * Clean up favourites when a post is deleted
     */
    public function cleanupPostFavourites(int $post_id): void
    {
        try {
            $type = get_post_type($post_id);
            if (!$type) return;
            $type = BASE . $type;
            // Delete using fluent interface
            $this->favourites->where([
                'type' => $type,
                'target_id' => $post_id
            ])->deleteResults();
            $this->listItems->where([
                'item_type' => $type,
                'item_id' => $post_id
            ])->deleteResults();
        } catch (Exception $e) {
            $this->logError('cleanupPostFavourites', [
                'error' => $e->getMessage(),
                'post_id' => $post_id
            ]);
        }
    }
    /**
     * Clean up favourites when a term is deleted
     */
    public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy): void
    {
        try {
            if (!isset($this->valid_types[$taxonomy])) {
                return;
            }
            // Delete using fluent interface
            $this->favourites->where([
                'type' => $taxonomy,
                'target_id' => $term_id
            ])->deleteResults();
            $this->listItems->where([
                'item_type' => $taxonomy,
                'item_id' => $term_id
            ])->deleteResults();
        } catch (Exception $e) {
            $this->logError('cleanupTermFavourites', [
                'error' => $e->getMessage(),
                'term_id' => $term_id,
                'taxonomy' => $taxonomy
            ]);
        }
    }
    /**
     * Cleanup orphaned favourites using CustomTable query method
     */
    public function cleanupOrphanedFavourites(): bool
    {
        try {
            // Delete favourites for non-existent users
            $this->favourites->query(
                "DELETE f FROM {table} f
                 LEFT JOIN {$GLOBALS['wpdb']->users} u ON f.user_id = u.ID
                 WHERE u.ID IS NULL"
            );
            // Delete favourites for non-existent posts
            $post_types = array_filter(
                array_keys($this->valid_types),
                fn($type) => $this->valid_types[$type]['table'] === 'post'
            );
            foreach ($post_types as $type) {
                $this->favourites->query(
                    "DELETE f FROM {table} f
                     LEFT JOIN {$GLOBALS['wpdb']->posts} p ON f.target_id = p.ID
                     WHERE f.type = %s AND p.ID IS NULL",
                    [$type]
                );
            }
            return true;
        } catch (Exception $e) {
            $this->logError('cleanupOrphanedFavourites', [
                'error' => $e->getMessage()
            ]);
            return false;
        }
    }
    /**
     * Helper methods
     */
    protected function buildParams(WP_REST_Request $request): array
@@ -748,112 +612,10 @@
        }
    }
    /**
     * Notify content owner of new favourite if configured
     *
     * @param string $type Content type
     * @param int $target_id Content ID
     * @param int $user_id User who favourited
     * @return void
     */
    protected function maybeNotifyOwner(string $type, int $target_id, int $user_id): void
    {
        try {
            $owner_id = $this->getContentOwner($type, $target_id);
            if ($owner_id && $owner_id !== $user_id) {
                JVB()->notification()->addNotification(
                    $owner_id,
                    'new_favourite',
                    [
                        'user_id' => $user_id,
                        'type' => $type,
                        'target_id' => $target_id
                    ]
                );
            }
        } catch (Exception $e) {
            // Silent fail - notifications are non-critical
        }
    }
    /**
     * Remove any existing notifications about a favorite action
     *
     * @param int $user_id User who removed the favorite
     * @param string $type Content type
     * @param int $target_id Content ID
     * @return void
     */
    protected function removeRelatedNotifications(int $user_id, string $type, int $target_id):void
    {
        try {
            // Get the content owner(s)
            $owner_ids = $this->getContentOwner($type, $target_id);
            if (!$owner_ids) {
                return;
            }
            $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
            foreach ($owner_ids as $owner_id) {
                // Skip if owner is the same as the user who unfavorited
                if ($owner_id === $user_id) {
                    continue;
                }
                global $wpdb;
                $notifications_table = $wpdb->prefix . BASE . 'notifications';
                // Find recent (within last 30 days) new_favourite notifications from this user for this content
                $notifications = $wpdb->get_results($wpdb->prepare(
                    "SELECT id FROM {$notifications_table}
                WHERE owner_id = %d
                AND action_user_id = %d
                AND type = 'new_favourite'
                AND target_id = %d
                AND target_type = %s
                AND created_at > DATE_SUB(%s, INTERVAL 30 DAY)",
                    $owner_id,
                    $user_id,
                    $target_id,
                    $type,
                    current_time('mysql')
                ));
                if (empty($notifications)) {
                    continue;
                }
                // Delete the notifications
                foreach ($notifications as $notification) {
                    $wpdb->delete(
                        $notifications_table,
                        ['id' => $notification->id],
                        ['%d']
                    );
                }
                // Invalidate notification cache for this user
//                if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
//                    JVB()->notification()->clearNotificationCache($owner_id);
//                }
            }
        } catch (Exception $e) {
            // Log but continue
            JVB()->error()->log(
                'favourites',
                'Error removing related notifications: ' . $e->getMessage(),
                ['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
                'warning'
            );
        }
    }
    public function maybeAcceptListInvite(int $user_id, string $email, array $data):void
    {
        if (array_key_exists('list_token', $data) && !empty($data['list_token'])) {
            $this->acceptListInvitation($data['list_token'], $email);
            JVB()->favourites()->acceptListShare($data['list_token'], $user_id);
        }
    }
jvb.php
@@ -259,6 +259,7 @@
add_action('plugins_loaded', 'jvb_site_definitions',1);
add_action('plugins_loaded', 'jvb_registrar_definitions',2);
add_action('plugins_loaded', 'jvb_field_definitions', 3);
add_action('plugins_loaded', 'jvb_options_definitions',3);
add_action('init', 'jvbLoadBase', 1);
add_action('init', 'jvb_integration_definitions',3);
add_action('init', 'jvb_field_section_definitions', 5);
@@ -279,6 +280,10 @@
    do_action('jvb_define_fields');
}
function jvb_options_definitions():void
{
    do_action('jvb_define_options');
}
function jvbLoadBase():void
{