Jake Vanderwerf
2026-04-15 c4aa5cdb5e90ad4b420e22772797d16980232a2b
=Updating custom tables to utilize CustomTable.php
1 files renamed
38 files added
76 files modified
2 files deleted
9890 ■■■■■ changed files
JVBase.php 7 ●●●●● patch | view | raw | blame | history
activate.php 9 ●●●●● patch | view | raw | blame | history
base/membership.php 4 ●●● patch | view | raw | blame | history
base/seo.php 27 ●●●● patch | view | raw | blame | history
inc/admin/SEOAdmin.php 3 ●●●● patch | view | raw | blame | history
inc/blocks/TimelineBlock.php 2 ●●● patch | view | raw | blame | history
inc/blocks/_setup.php 5 ●●●●● patch | view | raw | blame | history
inc/helpers/all.php 9 ●●●●● patch | view | raw | blame | history
inc/helpers/members.php 30 ●●●●● patch | view | raw | blame | history
inc/helpers/terms.php 16 ●●●●● patch | view | raw | blame | history
inc/integrations/GoogleMaps.php 3 ●●●● patch | view | raw | blame | history
inc/managers/ApprovalManager.php 432 ●●●●● patch | view | raw | blame | history
inc/managers/CustomTable.php 226 ●●●● patch | view | raw | blame | history
inc/managers/EmailManager.php 35 ●●●●● patch | view | raw | blame | history
inc/managers/ErrorHandler.php 38 ●●●●● patch | view | raw | blame | history
inc/managers/FavouritesManager.php 576 ●●●●● patch | view | raw | blame | history
inc/managers/ForumManager.php 99 ●●●●● patch | view | raw | blame | history
inc/managers/InvitationsManager.php 59 ●●●●● patch | view | raw | blame | history
inc/managers/KarmaManager.php 342 ●●●●● patch | view | raw | blame | history
inc/managers/NotificationManager.php 1799 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/Content.php 108 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/EmailDigests.php 391 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/Notifications.php 470 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/Preferences.php 124 ●●●●● patch | view | raw | blame | history
inc/managers/Notifications/_setup.php patch | view | raw | blame | history
inc/managers/ReferralManager.php 325 ●●●● patch | view | raw | blame | history
inc/managers/ResponseManager.php 64 ●●●●● patch | view | raw | blame | history
inc/managers/RoleManager.php 41 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/BreadcrumbManager.php 52 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/DataType/Date.php 6 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/DataType/Time.php 13 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/SchemaOutput.php 169 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action.php 37 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/Action.php 30 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/CommunicateAction.php 14 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/InteractAction.php 24 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/OrganizeAction.php 10 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/PlanAction.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/ScheduleAction.php 10 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Action/_setup.php 9 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Comment/Answer.php 14 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Comment/Comment.php 17 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Comment/Question.php 16 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/Comment/_setup.php 4 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/MediaObject/MediaObject.php 7 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/CollectionPage.php 15 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/ImageGallery.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/MediaGallery.php 15 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/WebPage/_setup.php 4 ●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/CreativeWork/_setup.php 1 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/Enumeration/DayOfWeek.php 19 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/ItemList/OfferCatalog.php 2 ●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/ItemList/_setup.php 2 ●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/StructuredValue/InteractionCounter.php 7 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/StructuredValue/PriceSpecification.php 3 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/StructuredValue/UnitPriceSpecification.php 14 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/StructuredValue/_setup.php 1 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/Intangible/_setup.php 1 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Thing/_setup.php 3 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/ThingSchema.php 45 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Helpers/_setup.php 1 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Helpers/arrayHelper.php 18 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Helpers/enumerationHelper.php 18 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/_setup.php 9 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/acceptedAnswerTrait.php 24 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/additionalPropertyTrait.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/addressTrait.php 8 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/aggregateRatingTrait.php 7 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/answerCountTrait.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/answerExplanationTrait.php 24 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/closesTrait.php 5 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/dayOfWeekTrait.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/downvoteCountTrait.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/founderTrait.php 1 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/geoTrait.php 10 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/hasOfferCatalogTrait.php 30 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/heightTrait.php 8 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/imageTrait.php 11 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/interactionTypeTrait.php 2 ●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/logoTrait.php 3 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/mainEntityTrait.php 13 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php 28 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/opensTrait.php 5 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/parentItemTrait.php 32 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/potentialActionTrait.php 14 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/scheduledTimeTrait.php 26 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/sharedContentTrait.php 27 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/suggestedAnswerTrait.php 24 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/targetTrait.php 23 ●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/upvoteCountTrait.php 21 ●●●●● patch | view | raw | blame | history
inc/managers/SEO/render/Traits/_Properties/widthTrait.php 8 ●●●● patch | view | raw | blame | history
inc/managers/TaxonomyRelationships.php 360 ●●●●● patch | view | raw | blame | history
inc/managers/UserTermsManager.php 643 ●●●● patch | view | raw | blame | history
inc/managers/VerifyEntryManager.php 163 ●●●●● patch | view | raw | blame | history
inc/managers/_setup.php 5 ●●●●● patch | view | raw | blame | history
inc/managers/queue/Queue.php 4 ●●●● patch | view | raw | blame | history
inc/registrar/Fields.php 3 ●●●● patch | view | raw | blame | history
inc/registrar/Posts.php 117 ●●●●● patch | view | raw | blame | history
inc/registrar/Registrar.php 157 ●●●● patch | view | raw | blame | history
inc/registrar/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/registrar/config/Breadcrumbs.php 2 ●●● patch | view | raw | blame | history
inc/registrar/config/SEO.php 2 ●●● patch | view | raw | blame | history
inc/registrar/config/seo/Resolver.php 29 ●●●● patch | view | raw | blame | history
inc/registrar/config/seo/Schema.php 266 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/Field.php 16 ●●●●● patch | view | raw | blame | history
inc/registrar/fields/UploadField.php 12 ●●●● patch | view | raw | blame | history
inc/registrar/helpers/HideSingle.php 103 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/MakeTimelineType.php 47 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/MakeTrackChanges.php 121 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/MakeVerification.php 19 ●●●●● patch | view | raw | blame | history
inc/registry/CheckCustomTables.php 1250 ●●●● patch | view | raw | blame | history
inc/rest/routes/FavouritesRoutes.php 8 ●●●● patch | view | raw | blame | history
inc/rest/routes/TermRoutes.php 17 ●●●● patch | view | raw | blame | history
inc/rest/routes/VoteRoutes.php 216 ●●●●● patch | view | raw | blame | history
inc/ui/CRUDSkeleton.php 2 ●●● patch | view | raw | blame | history
jvb.php 2 ●●●●● patch | view | raw | blame | history
JVBase.php
@@ -20,6 +20,7 @@
use JVBase\admin\SEOAdmin;
use JVBase\managers\AdminPages;
use JVBase\managers\NotificationManager;
use JVBase\managers\TaxonomyRelationships;
use JVBase\managers\UserTermsManager;
use JVBase\registrar\Registrar;
use JVBase\rest\routes\FeedRoutes;
@@ -106,6 +107,7 @@
//          'uploads'   => new UploadManager(),
            'userTerms' => new UserTermsManager(),
            'email'     => new EmailManager(),
            'terms'     => new TaxonomyRelationships(),
        ];
        $this->routes = [
@@ -387,4 +389,9 @@
    {
        return $this->managers['schemaHelper'];
    }
    public function termRelationships():TaxonomyRelationships
    {
        return $this->managers['terms'];
    }
}
activate.php
@@ -4,6 +4,8 @@
use JVBase\managers\Cache;
use JVBase\managers\CustomTable;
use JVBase\managers\DirectoryManager;
use JVBase\managers\ErrorHandler;
use JVBase\managers\InvitationsManager;
use JVBase\managers\queue\Queue;
use JVBase\managers\ReferralManager;
use JVBase\managers\RoleManager;
@@ -17,7 +19,6 @@
function jvbActivatePlugin():void
{
//  $validator = new JVBase\utility\Validator();
//  $validation = $validator->validateAll();
//  error_log('Validation result: '.print_r($validation, true));
@@ -160,16 +161,16 @@
            'time'              => '12:03am tomorrow',
        ],
        //NotificationManager.php
        'jvb_notification_digest_daily' =>
        BASE.'notification_digest_daily' =>
            [
                'time'  => '8:08am tomorrow',
            ],
        'jvb_notification_digest_weekly' =>
        BASE.'notification_digest_weekly' =>
            [
                'time'       => 'monday 6:07am',
                'recurrence' => 'weekly',
            ],
        'jvb_notification_digest_monthly' =>
        BASE.'notification_digest_monthly' =>
            [
                'time'       => '2025-05-05 9:00am',
                'recurrence' => 'monthly',
base/membership.php
@@ -27,5 +27,7 @@
 * ]
 */
$membership = apply_filters('jvb_membership', []);
$membership = apply_filters('jvb_membership', [
    'can_invite' => [],
]);
define('JVB_MEMBERSHIP', $membership);
base/seo.php
@@ -112,19 +112,37 @@
                    'type'  => 'JVBase\managers\SEO\render\Thing\CreativeWork\WebSite',
                    'name' => get_bloginfo('name'),
                    'url' => get_home_url(),
                    'id' => get_home_url() . '#website',
                    'id' => get_home_url() . '/#website',
                    'description' => get_bloginfo('description'),
                    'inLanguage' => 'en-CA'
                ],
                default => []
            },
            'archive' => [
                'type'  => 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage',
                'type'  => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage',
                'name'  => '{{name}}'
            ],
            default => [],
        };
        return apply_filters(BASE.ucfirst($type).ucfirst($format).'Default', $defaults);
        error_log('Getting defaults for: '.BASE.ucfirst($type).ucfirst($format).'Default');
        $result = apply_filters(BASE.ucfirst($type).ucfirst($format).'Default', $defaults);
        if ($format === 'reference' && empty($result)) {
            $full = self::getDefault($type, 'schema');
            if (!empty($full)) {
                $result = [
                    'type'  => $full['type'],
                    'name'  => $full['name'],
                    'description'=> $full['description']
                ];
                $check = ['id', 'url'];
                foreach ($check as $ch) {
                    if (array_key_exists($ch, $full)) {
                        $result[$ch] = $full[$ch];
                    }
                }
            }
        }
        return $result;
    }
    public static function updateHistory(string $type, string $format, array $newest):bool
@@ -257,8 +275,9 @@
        unset($config['type']);
        $class = new $className();
        foreach ($config as $property => $value) {
            if (is_array($value)) {
            if (is_array($value) && array_key_exists('type', $value)) {
                $value = self::classFromConfig($value, $meta);
            }
            $method = 'set'.ucfirst($property);
inc/admin/SEOAdmin.php
@@ -2,7 +2,6 @@
namespace JVBase\admin;
use JVBase\meta\Form;
use JVBase\meta\Sanitizer;
use JVBase\registrar\Registrar;
use JVBase\ui\Tabs;
@@ -29,7 +28,7 @@
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\Webpage'     => ' - - WebPage',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\AboutPage'   => ' - - - About Page',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CheckoutPage'    => ' - - - Checkout Page',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage'  => ' - - - Collection Page',
        'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage' => ' - - - Collection Page',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\ContactPage'     => ' - - - Contact Page',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\FAQPage'         => ' - - - FAQ Page',
        'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\ItemPage'        => ' - - - Item Page',
inc/blocks/TimelineBlock.php
@@ -48,7 +48,7 @@
    public function render(array $attributes, string $content, WP_Block $block)
    {
        global $post;
        $registrar = Registrar::getInstance($post->post_type));
        $registrar = Registrar::getInstance($post->post_type);
        if (!$post || !$registrar || !$registrar->hasFeature('is_timeline') ) {
            return '';
        }
inc/blocks/_setup.php
@@ -21,15 +21,16 @@
    new JVBase\blocks\FAQBlock();
}
if (!empty(Registrar::getFeatured('is_gallery'))) {
if (!empty(Registrar::getFeatured('is_glossary'))) {
    require(JVB_DIR . '/inc/blocks/GlossaryBlock.php');
    new JVBase\blocks\GlossaryBlock();
}
if (!empty(Registrar::getFeatured('is_timeline'))) {
    require(JVB_DIR . '/inc/blocks/TimelineBlock.php');
    new JVBase\blocks\TimelineBlock();
}
require(JVB_DIR . '/inc/blocks/SummaryBlock.php');
new JVBase\blocks\SummaryBlock();
inc/helpers/all.php
@@ -57,3 +57,12 @@
        update_option(BASE.'do_these_once', $options);
    }
}
function jvbResponse(bool $success, ?string $msg = null):array
{
    return [
        'success'   => $success,
        'message'   => is_null($msg) ? ($success ? 'Completed successfully' : 'Something went wrong') : $msg
    ];
}
inc/helpers/members.php
@@ -2,6 +2,7 @@
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
@@ -217,3 +218,32 @@
        $user->roles)
    ))[0];
}
function  jvbUserProfileLink(int $userID):string|false
{
    $cache = Cache::for('userLink')->connect('user');
    return $cache->remember(
        $userID,
        function() use ($userID) {
            $user = get_userdata($userID);
            if (!$user) {
                return false;
            }
            $role = jvbUserRole($userID);
            $registrar = Registrar::getInstance($role);
            if (!$registrar || !$registrar->profile_link) {
                return false;
            }
            $link = get_user_meta($userID, BASE.'profile_link', true);
            //Try to create it
            if (empty($link)) {
                $link = JVB()->roles()->addUserLink($user, $role);
                if (!$link) {
                    return false;
                }
            }
            $status = get_post_status($link);
            return ($status === 'publish') ? get_the_permalink($link) : false;
        }
    );
}
inc/helpers/terms.php
@@ -6,6 +6,19 @@
    exit;
}
function jvbGetTermOwners(int $termID, bool $includeManagers = true):array
{
    $owners = get_term_meta($termID, BASE.'owners', true);
    $owners = empty($owners) ? [] : $owners;
    if ($includeManagers) {
        $managers = get_term_meta($termID, BASE.'managers', true);
        $managers = empty($managers) ? [] : $managers;
        $owners = array_merge($owners, $managers);
    }
    return $owners;
}
/**
 * @param string $term
 * @param int|false $ID
@@ -128,7 +141,8 @@
    if ($users === '') {
        $term = get_term($termID);
        $taxonomy = $term->taxonomy;
        if (taxIsJVBContentTax($taxonomy)) {
        $registrar = Registrar::getInstance($taxonomy);
        if ($registrar->hasFeature('is_content')) {
            $posts = new WP_Query([
                'post_type'    => jvbCheckBase($user),
                'posts_per_page'    => -1,
inc/integrations/GoogleMaps.php
@@ -5,6 +5,7 @@
 */
namespace JVBase\integrations;
use JVBase\registrar\Registrar;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -541,7 +542,7 @@
            return true;
        }
        if (isJVBContentTax() && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) {
        if (!empty(Registrar::getFeatured('is_content', 'term')) && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) {
            return true;
        }
inc/managers/ApprovalManager.php
New file
@@ -0,0 +1,432 @@
<?php
namespace JVBase\managers;
use DateTime;
use JVBase\registrar\Registrar;
use JVBase\managers\CustomTable;
if (!defined('ABSPATH')) {
    exit;
}
class ApprovalManager {
    protected array $tables =[];
    protected int $expiresAt = 21; //days for request
    protected int $requiredVotes = 3; //Number of votes before a term is approved
    public function __construct()
    {
        $this->defineTables();
        if (empty($this->tables)) {
            return;
        }
    }
    protected function defineTables():void
    {
        $types = Registrar::getFeatured('approve_new');
        foreach ($types as $type) {
            $requests = CustomTable::for("approval_{$type}_requests");
            $requests->setColumns([
                'id'                => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'user_id'           => "{$requests->getUserIDType()} NOT NULL",
                'name'              => 'varchar(255) NOT NULL',
                'parent_id'         => 'bigint(20) unsigned DEFAULT 0',
                'status'            => "ENUM('pending', 'approved', 'rejected', 'appealed', 'expired') DEFAULT 'pending'",
                'required_approvals'=> 'int unsigned DEFAULT 3',
                'approvals' => 'int unsigned DEFAULT 0',
                'rejections'=> 'int unsigned DEFAULT 0',
                'expires_at'        => 'datetime DEFAULT NULL',
                'created_at'        => 'datetime DEFAULT CURRENT_TIMESTAMP',
                'updated_at'        => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
                'approved_by'       => 'json DEFAULT NULL',
                'rejected_by'       => 'json DEFAULT NULL',
                'created_item_id'   => 'bigint(20) unsigned DEFAULT NULL',
            ]);
            $requests->setKeys([
                ['key' => 'PRIMARY', 'value' => 'id'],
                ['key' => 'UNIQUE', 'value' => '`unique_request` (`user_id`, `name`)'],
                '`name` (`name`)',
                '`name_parent` (`name`, `parent_id`)',
                '`status` (`status`)',
                '`expiring_requests` (`status`, `expires_at`)'
            ]);
            $base = BASE;
            $requests->setConstraints([
                "CONSTRAINTS `{$base}{$type}_approval_requester` FOREIGN KEY (`user_id`)
                REFERENCES `{$requests->getUserTable()}` (`ID`) ON DELETE CASCADE",
                "CONSTRAINTS `{$base}{$type}_approval_parent_term` FOREIGN KEY (`parent_id`)
                REFERENCES `{$requests->getTermTable()}` (`term_id`) ON DELETE CASCADE"
            ]);
            $requests->defineTable();
            $votes = CustomTable::for("approval_{$type}_votes");
            $votes->setColumns([
                'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'request_id'    => 'bigint(20) unsigned NOT NULL',
                'user_id'       => "{$votes->getUserIDType()} NOT NULL",
                'vote'          => "ENUM('approve', 'reject', 'dismiss') NOT NULL",
                'notes'         => 'text DEFAULT NULL',
                'created_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP',
                'updated_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
            ]);
            $votes->setKeys([
                ['key' => 'PRIMARY', 'value' => 'id'],
                ['key' => 'UNIQUE', 'value' => '`unique_vote` (`request_id`, `user_id`)'],
                '`user_votes` (`user_id`)'
            ]);
            $votes->setConstraints([
                "CONSTRAINT `{$base}{$type}_user_approval_request` FOREIGN KEY (`request_id`)
                REFERENCES `{$requests->getFullTableName()} (`id`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}{$type}_user_approval_voter` FOREIGN KEY (`user_id`)
                REFERENCES `{$votes->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $votes->defineTable();
            $this->tables[$type] = [
                'requests'  => $requests,
                'votes'     => $votes
            ];
        }
    }
    public function canApprove(int $userID, string $type):bool
    {
        $type = jvbNoBase($type);
        if (!array_key_exists($type, $this->tables)) {
            return false;
        }
        return user_can($userID, "approve_{$type}");
    }
    protected function requests(string $type):?CustomTable
    {
        $type = jvbNoBase($type);
        if (!array_key_exists($type, $this->tables)){
            return null;
        }
        return $this->tables[$type]['requests']??null;
    }
    protected function votes(string $type):?CustomTable
    {
        $type = jvbNoBase($type);
        if (!array_key_exists($type, $this->tables)){
            return null;
        }
        return $this->tables[$type]['votes']??null;
    }
    public function response(bool $success, string $msg = ''):array
    {
        $return = ['success' => $success];
        if ($msg !== '') {
            $return['message'] = $msg;
        }
        return $return;
    }
    public function createApproval(int $userID, string $type, string $name, int $parentID = 0):array
    {
        if (!$this->canApprove($userID, $type)) {
            return $this->response(false, 'Cannot create request');
        }
        $type = jvbNoBase($type);
        if (!array_key_exists($type, $this->tables)) {
            $this->response(false, 'Invalid type');
        }
        $table = $this->requests($type);
        if (!$table) {
            return $this->response(false, 'Invalid type');
        }
        //Check for existing request
        $existing = $table->get([
            'name'  => sanitize_text_field($name)
        ]);
        if (!$existing) {
            $this->approve($userID, $existing->id);
            return $this->response(true, 'Existing request - added your vote!');
        }
        if ($parentID > 0) {
            $parent = get_term($parentID, jvbCheckBase($type));
            if (!$parent || is_wp_error($parent)) {
                $parent = 0;
            }
        }
        $date = new DateTime('+' . $this->expiresAt . ' days');
        $expires = $date->format('Y-m-d H:i:s');
        $result = $table->create([
            'user_id'   => $userID,
            'name'      => sanitize_text_field($name),
            'parent_id' => $parentID,
            'expires_at'=> $expires
        ]);
        return $this->response((bool)$result, $result?'Request created successfully' : 'Request could not be created');
    }
    public function markApproval(int $request_id, int $userID, string $type, string $vote, ?string $note = null):array
    {
        if (!$this->canApprove($userID, $type)) {
            return $this->response(false, 'Sorry, you cannot do this.');
        }
        $requests = $this->requests($type);
        if (!$requests) {
            return $this->response(false, 'Invalid type');
        }
        $request = $requests->get(['request_id' => $request_id]);
        if (!$request) {
            return $this->response(false, 'Invalid request id');
        }
        $votes = $this->votes($type);
        if (!$votes) {
            return $this->response(false, 'Invalid type');
        }
        if (!in_array($vote, ['approve', 'reject', 'dismiss'])) {
            return $this->response(false, 'Invalid vote');
        }
        $response = $votes->findOrCreate([
            'request_id'    => $request_id,
            'user_id'       => $userID
        ], [
            'vote'  => 'approve',
            'notes' => $note ? sanitize_text_field($note) : $note
        ]);
        if (!$response) {
            return $this->response(false, 'Could not store vote for some reason.');
        }
        switch ($vote) {
            case 'reject':
                $request['rejections']++;
                break;
            case 'approve':
                $request['approvals']++;
                break;
            default:
                return $this->response(true, 'Successfully dismissed approval request.');
        }
        if ($request['rejections'] === $request['required_approvals']) {
            $this->finalizeDenial($request_id, $type);
        } else if ($request['approvals'] === $request['required_approvals']) {
            $this->finalizeApproval($request_id, $type);
        }
        $updated = $requests->update([
            'rejections'=> $request['rejections'],
            'approvals' => $request['approvals']
        ],
        [
            'request_id' => $request_id
        ]);
        if ($updated) {
            return $this->response(true, 'Successfully voted');
        }
        return $this->response(false, 'Could not finalize vote for some reason');
    }
    public function approveRequest(int $request_id, int $userID, string $type, ?string $note = null):array
    {
        return $this->markApproval($request_id, $userID, $type, 'approve', $note);
    }
    public function denyRequest(int $request_id, int $userID, string $type, string $note = ''):array
    {
        return $this->markApproval($request_id, $userID, $type, 'reject', $note);
    }
    public function dismissRequest(int $request_id, int $userID, string $type, string $note = ''):array
    {
        return $this->markApproval($request_id, $userID, $type, 'dismiss', $note);
    }
    public function isApproved(int $request_id, string $type):bool
    {
        $table = $this->requests($type);
        if (!$table) {
            return false;
        }
        $request = $table->get(['request_id' => $request_id]);
        if (!$request) {
            return false;
        }
        $votes = $this->votes($type);
        if (!$votes) {
            return false;
        }
        $total = $votes->getMany([
            'request_id'    => $request_id,
            'status'        => ['IN' => ['approve', 'reject']]
        ]);
        $approvals = count(array_filter($total, function($item) { return $item->status === 'approve'; }));
        $denials = count(array_filter($total, function($item) { return $item->status === 'reject'; }));
        if ($denials === $this->requiredVotes) {
            return false;
        }
        return $approvals === $this->requiredVotes;
    }
    public function finalizeApproval(int $request_id, string $type):void
    {
        $requests = $this->requests($type);
        $request = $requests->get(['request_id' => $request_id]);
        if (!$request){
            error_log('Could not finalize denial for request: '.$request_id);
            return;
        }
        $updatedID = null;
        $registrar = Registrar::getInstance($type);
        if ($registrar->getType() === 'term') {
            $term = wp_insert_term($request['name'], $registrar->getBased(), ['parent' => $request['parent_id']]);
            if (!is_wp_error($term)) {
                $updatedID = $term['term_id'];
            }
        } elseif ($registrar->getType() === 'user') {
        }
        $updates = [
            'status'    => 'approved'
        ];
        if ($updatedID) {
            $updates['created_item_id'] = $updatedID;
        }
        $updated = $requests->update(
            $updates,
            [
                'request_id' => $request_id
            ]);
        JVB()->notification()->addNotification($request['user_id'], $type.'approved', null, 'Your suggestion "'.$request['name'].'" was denied.');
    }
    public function finalizeDenial(int $request_id, string $type):void
    {
        $requests = $this->requests($type);
        $request = $requests->get(['request_id' => $request_id]);
        if (!$request){
            error_log('Could not finalize denial for request: '.$request_id);
            return;
        }
        $updated = $requests->update([
            'status'    => 'rejected'
        ],
            [
                'request_id' => $request_id
            ]);
        JVB()->notification()->addNotification($request['user_id'], $type.'denied', null, 'Your suggestion "'.$request['name'].'" was denied.');
    }
    public function getApprovalRequests(int $userID, ?string $type = null):array
    {
        $return = [];
        if ($type) {
            if (!array_key_exists($type, $this->tables)) {
                return $return;
            }
            $requests = $this->requests($type);
            $active = $requests->pluck('request_id', [
                'status' => 'pending'
            ]);
            $votes = $this->votes($type);
            $voted = $votes->pluck('request_id',[
                'user_id'   => $userID
            ]);
            $all = array_diff($active, $voted);
            $allRequests = $requests->getMany([
                'where' => [
                    'request_id' => ['IN' => $all]
                ]
            ]);
            $return = array_map(function ($request) use ($type) {
                return $this->format_for_notification($request, $type);
            }, $allRequests);
        } else {
            foreach ($this->tables as $type => $tables) {
                $requests = $tables['requests'];;
                $active = $requests->pluck('request_id', [
                    'status'    => 'pending',
                ]);
                $votes = $tables['votes'];
                $voted = $votes->pluck('request_id', [
                    'user_id'   => $userID
                ]);
                $all = array_diff($active, $voted);
                $allRequests = $requests->getMany([
                    'where' => [
                        'request_id' => ['IN' => $all]
                    ]
                ]);
                $return = array_merge($return, array_map(function ($request) use ($type) {
                    return $this->format_for_notification($request, $type);
                }, $allRequests));
            }
        }
        return $return;
    }
    protected function format_for_notification(array $request, string $type): array
    {
        $registrar = Registrar::getInstance($type);
        $message = '';
        $user = get_userdata($request['user_id']);
        if ($user) {
            $message = $user->first_name;
        } else {
            $message = 'Someone';
        }
        $message .= ' thinks we need a new '.$registrar->getSingular().'.
        They think: "'.$request['name'].'"
        ';
        if ($request['parent_id'] > 0) {
            $parent = get_term($request['parent_id'], $registrar->getBased());
            if ($parent && !is_wp_error($parent)) {
                $message .= 'And nest it under: '.$parent->name.'
                ';
            }
        }
        $message .= '
        What say you?';
        return [
            'id'        => $request['id'],
            'type'      => $type.'_new',
            'message'   => $message,
            'created_at'=> $request['created_at'],
            'status'    => 'pending',
            'requires_action' => true,
            'action_taken'  => false,
            'icon'      => $registrar->getIcon(),
            'priority'  => 'normal',
            'target'    => [
                'id'    => $request['id'],
                'type'  => 'approval'
            ],
            'actions'   => [
                'approve'   => 'Approve',
                'deny'      => 'Deny',
                'dismiss'   => 'Dismiss'
            ]
        ];
    }
}
inc/managers/CustomTable.php
@@ -34,8 +34,12 @@
    protected static string $userTable;
    protected static string $userIDType;
    protected static string $termTable;
    protected static string $termIDType;
    protected static string $postIDType;
    protected static string $postTable;
    protected Cache $cache;
    /**
     * Fluent factory method
@@ -54,6 +58,19 @@
        return self::$instances[$tableName];
    }
    public static function destroyInstance(string $tableName):void
    {
        if (isset(self::$instances[$tableName])) {
            unset(self::$instances[$tableName]);
        }
    }
    public static function getInstance(string $tableName):self|false
    {
        if (!isset(self::$instances[$tableName])) {
            return false;
        }
        return self::$instances[$tableName];
    }
    public function ensureDefined() {
        $this->ensureUserTable();
@@ -94,12 +111,25 @@
            }
        }
        protected function ensureTermTable():void
        {
            if (!isset(static::$termTable)) {
                static::$termTable = $this->wpdb->terms;
            }
        }
        protected function ensurePostIDType():void
        {
            if (!isset(static::$postIDType)) {
                static::$postIDType = $this->getColumnType($this->wpdb->posts, 'ID');
            }
        }
        protected function ensurePostTable():void
        {
            if (!isset(static::$postTable)) {
                static::$postTable = $this->wpdb->posts;
            }
        }
    /**
     *
@@ -165,11 +195,12 @@
        }
        // Constraints
        foreach ($this->constraints as $constraint => $references) {
            $parts[] = "CONSTRAINT {$constraint} REFERENCES {$references}";
        foreach ($this->constraints as $constraint) {
            $parts[] = $constraint;
        }
        $this->definition = "(\n    " . implode(",\n    ", $parts) . "\n)";
        $this->createTable();
        return $this;
    }
@@ -190,6 +221,11 @@
            error_log('[CustomTable]No definition set for '.$this->tableName);
            return;
        }
        if ($this->wpdb->get_var("SHOW TABLES LIKE '{$this->fullTableName}'")) {
            return;
        }
        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        error_log('Creating Database Table: '. $this->tableName);
        $charset = self::$charsetCollate;
        $schema = "CREATE TABLE IF NOT EXISTS {$this->fullTableName} {$this->definition} {$charset}";
        $this->wpdb->flush();
@@ -232,6 +268,8 @@
        $this->fullTableName = $wpdb->prefix . apply_filters('jvb_base', BASE) . $tableName;
        $this->useTransactions = $useTransactions;
        $this->cache = Cache::for($tableName);
        $usersStatus = $this->wpdb->get_row("SHOW TABLE STATUS LIKE '{$this->wpdb->users}'");
        $parentCollation = $usersStatus->Collation ?? 'utf8mb4_general_ci';
        self::$charsetCollate = "DEFAULT CHARACTER SET utf8mb4 COLLATE {$parentCollation}";
@@ -409,6 +447,8 @@
            return false;
        }
        $this->cache->flush();
        return $this->wpdb->insert_id;
    }
@@ -439,26 +479,19 @@
     * );
     * // Returns: ['id' => 456, 'created' => false, 'record' => object]
     */
    public function findOrCreate(array $searchData, array $createData = []): array
    public function findOrCreate(array $searchData, array $createData = []):int|false
    {
        $record = $this->get($searchData);
        if ($record) {
            return [
                'id' => $record->id ?? 0,
                'created' => false,
                'record' => $record
            ];
            if (!empty($createData)) {
                return $this->update($createData, $searchData);
            }
            return $record->id;
        }
        $data = array_merge($searchData, $createData);
        $id = $this->insert($data);
        return [
            'id' => $id,
            'created' => true,
            'record' => $this->get(['id' => $id])
        ];
        return $this->insert($data);
    }
    /**
@@ -536,10 +569,17 @@
     */
    public function get(array $where, string $output = OBJECT): object|array|null
    {
        $query = "SELECT * FROM {$this->fullTableName} WHERE " . $this->buildWhereClause($where);
        $values = array_values($where);
        return $this->cache->remember(
            $this->cache->generateKey(array_merge(['output' => $output], $where)),
            function() use ($where, $output) {
                $query = "SELECT * FROM {$this->fullTableName}";
                $where = $this->buildWhereClause($where);
                $query .= " WHERE {$where['sql']}";
                $values = $where['values'];
        return $this->wpdb->get_row($this->wpdb->prepare($query, $values), $output);
                return $this->wpdb->get_row($this->wpdb->prepare($query, $values), $output);
            }
        );
    }
    /**
@@ -559,34 +599,76 @@
     */
    public function getMany(array $args = [], string $output = OBJECT): array
    {
        $query = "SELECT * FROM {$this->fullTableName}";
        $values = [];
        return $this->cache->remember(
            $this->cache->generateKey(array_merge($args, ['output' => $output])),
            function () use ($args, $output) {
                $query = "SELECT * FROM {$this->fullTableName}";
                $values = [];
        // WHERE clause
        if (!empty($args['where'])) {
            $query .= " WHERE " . $this->buildWhereClause($args['where']);
            $values = array_merge($values, array_values($args['where']));
        }
                // WHERE clause
                if (!empty($args['where'])) {
                    $clause = $this->buildWhereClause($args['where']);
        // ORDER BY
        if (!empty($args['orderby'])) {
            $orderby = sanitize_sql_orderby($args['orderby']);
            $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC';
            $query .= " ORDER BY {$orderby} {$order}";
        }
                    $query .= " WHERE {$clause['sql']}";
                    $values = array_merge($values, $clause['values']);
                }
        // LIMIT
        if (!empty($args['limit'])) {
            $limit = absint($args['limit']);
            $offset = !empty($args['offset']) ? absint($args['offset']) : 0;
            $query .= " LIMIT {$offset}, {$limit}";
        }
                // ORDER BY
                if (!empty($args['orderby'])) {
                    $orderby = sanitize_sql_orderby($args['orderby']);
                    $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC';
                    $query .= " ORDER BY {$orderby} {$order}";
                }
        if (empty($values)) {
            return $this->wpdb->get_results($query, $output);
        }
                // LIMIT
                if (!empty($args['limit'])) {
                    $limit = absint($args['limit']);
                    $offset = !empty($args['offset']) ? absint($args['offset']) : 0;
                    $query .= " LIMIT {$offset}, {$limit}";
                }
        return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
                if (empty($values)) {
                    return $this->wpdb->get_results($query, $output);
                }
                return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output);
            }
        );
    }
    /**
     * Get a specific column value from all matches
     * @param string $column
     * @param array $where
     * @return array
     */
    public function pluck(string $column, array $where = [], ?string $orderby = null, ?string $order = null, ?int $limit = null): array
    {
        $key = array_merge($where, ['column' => $column, 'orderby' => $orderby, 'order' => $order, 'limit' => $limit]);
        return $this->cache->remember(
            $this->cache->generateKey($key),
            function() use ($column, $where, $orderby, $order, $limit) {
                if (!empty($this->columns) && !array_key_exists($column, $this->columns)) {
                    $this->logError('pluck', ['reason' => "Column '{$column}' not in definition"]);
                    return [];
                }
                $args = ['where' => $where];
                if ($orderby) {
                    $args['orderby'] = $orderby;
                }
                if ($order) {
                    $args['order'] = $order;
                }
                if ($limit) {
                    $args['limit'] = $limit;
                }
                return array_column($this->getMany($args), $column);
            }
        );
    }
    /**
@@ -597,19 +679,26 @@
     */
    public function count(array $where = []): int
    {
        $query = "SELECT COUNT(*) FROM {$this->fullTableName}";
        $values = [];
        return $this->cache->remember(
            $this->cache->generateKey(array_merge(['source' => 'count'], $where)),
            function() use ($where) {
                $query = "SELECT COUNT(*) FROM {$this->fullTableName}";
                $values = [];
        if (!empty($where)) {
            $query .= " WHERE " . $this->buildWhereClause($where);
            $values = array_values($where);
        }
                if (!empty($where)) {
                    $clause = $this->buildWhereClause($where);
                    $query .= " WHERE {$clause['sql']}";
                    $values = $clause['values'];
                }
        if (empty($values)) {
            return (int) $this->wpdb->get_var($query);
        }
                if (empty($values)) {
                    return (int) $this->wpdb->get_var($query);
                }
        return (int) $this->wpdb->get_var($this->wpdb->prepare($query, $values));
                return (int) $this->wpdb->get_var($this->wpdb->prepare($query, $values));
            }
        );
    }
    /**
@@ -660,6 +749,7 @@
        if ($result === false) {
            $this->logError('update', ['data' => $data, 'where' => $where]);
        }
        $this->cache->flush();
        return $result;
    }
@@ -689,6 +779,7 @@
        if ($result === false) {
            $this->logError('delete', ['where' => $where]);
        }
        $this->cache->flush();
        return $result;
    }
@@ -811,7 +902,7 @@
        } catch (Exception $e) {
            $this->rollback();
            $this->logError('transaction', ['error' => $e->getMessage()]);
            throw $e;
            return false;
        }
    }
@@ -866,11 +957,21 @@
        $this->ensureTermIDType();
        return static::$termIDType;
    }
    public function getTermTable():string
    {
        $this->ensureTermTable();
        return static::$termTable;
    }
    public function getPostIDType():string
    {
        $this->ensurePostIDType();
        return static::$postIDType;
    }
    public function getPostTable():string
    {
        $this->ensurePostTable();
        return static::$postTable;
    }
    // =========================================================================
    // PRIVATE HELPERS
@@ -909,18 +1010,35 @@
    /**
     * Build WHERE clause from associative array
     */
    private function buildWhereClause(array $where): string
    private function buildWhereClause(array $where):array
    {
        $conditions = [];
        $values = [];
        foreach ($where as $column => $value) {
            $column_safe = esc_sql($column);
            if ($value === null) {
                $conditions[] = "`{$column_safe}` IS NULL";
            } elseif (is_array($value) && count($value) === 2 && is_string($value[0])) {
                [$operator, $operand] = $value;
                $operator = strtoupper(trim($operator));
                if (in_array($operator, ['IN', 'NOT IN']) && is_array($operand)) {
                    $placeholders = implode(',', array_map([$this, 'getPlaceholder'], $operand));
                    $conditions[] = "`{$column_safe}` {$operator} ({$placeholders})";
                    $values       = array_merge($values, $operand);
                } else {
                    // <, >, <=, >=, !=, LIKE, etc.
                    $conditions[] = "`{$column_safe}` {$operator} " . $this->getPlaceholder($operand);
                    $values[]     = $operand;
                }
            } else {
                $conditions[] = "`{$column_safe}` = " . $this->getPlaceholder($value);
                $values[]     = $value;
            }
        }
        return implode(' AND ', $conditions);
        return ['sql' => implode(' AND ', $conditions), 'values' => $values];
    }
    /**
inc/managers/EmailManager.php
@@ -756,11 +756,35 @@
        );
    }
    public function grid(array $items, int $columns = 2):string
    public function grid(array $items, int $columns = 2, string $title = '', string|array $description = '', string $after = ''):string
    {
        $width = floor(100 / $columns) - 2; // 2% gap
        $html = '<div style="display:table;width:100%;margin:20px 0;">';
        $html = '';
        if (!empty($title) || !empty($description)) {
            $html .= '<div>';
            if (!empty($title)) {
                $html .= sprintf(
                    '<h2>%s</h2>',
                    $title
                );
            }
            if (!empty($description)) {
                if (is_string($description)) {
                    if (str_starts_with($description, '<p>')) {
                        $html .= $description;
                    }else {
                        $html .= sprintf(
                            '<p>%s</p>',
                            $description
                        );
                    }
                } else {
                    $html .= implode('',array_map(function ($p) { return sprintf('<p>%s</p>', $p); }, $description));
                }
            }
        }
        $html .= '<div style="display:table;width:100%;margin:20px 0;">';
        foreach ($items as $index => $item) {
            if ($index > 0 && $index % $columns === 0) {
                $html .= '</div><div style="display:table;width:100%;margin:20px 0;">';
@@ -771,11 +795,16 @@
                $item
            );
        }
        $html .= '</div>';
        $html .= '</div>'.$after;
        if (!empty($title) || !empty($description)) {
            $html .= '</div>';
        }
        return $html;
    }
    public function image(string $src, string $alt = '', int $maxWidth = 600):string
    {
        return sprintf(
inc/managers/ErrorHandler.php
@@ -24,11 +24,14 @@
        'critical' => 4
    ];
    protected CustomTable $table;
    public function __construct()
    {
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->tableName = $wpdb->prefix . BASE . 'error_log';
        $this->defineTables();
//        global $wpdb;
//        $this->wpdb = $wpdb;
//      $this->tableName = $wpdb->prefix . BASE . 'error_log';
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
@@ -39,6 +42,35 @@
//        add_filter(BASE.'admin_action_filter', [$this, 'adminActionFilter'], 10, 3);
    }
    public function defineTables():void
    {
        $table = CustomTable::for('error_log');
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'error_type'    => 'varchar(50) NOT NULL',
            'component'     => 'varchar(100) NOT NULL',
            'method'        => 'varchar(100) DEFAULT NULL',
            'page_url'      => 'varchar(255) DEFAULT NULL',
            'message'       => 'text NOT NULL',
            'context'       => 'JSON',
            'severity'      => 'ENUM(\'high\',\'normal\',\'low\') DEFAULT \'normal\'',
            'user_id'       => $table->getUserIDType().' DEFAULT NULL',
            'user_was_logged_in'    => 'tinyint(1) NOT NULL',
            'source'        => 'ENUM(\'frontend\', \'backend\') NOT NULL',
            'created_at'    => 'timestamp DEFAULT CURRENT_TIMESTAMP',
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => 'id'],
            '`created_at` (`created_at`)',
            '`component_severity_date` (`component`, `severity`, `created_at`)',
            '`error_type_date` (`error_type`, `created_at`)',
            '`severity_date` (`severity`, `created_at`)'
        ]);
        $table->defineTable();
        $this->table = $table;
    }
    public function registerAdminAction():void
    {
        $admin = JVB()->admin();
inc/managers/FavouritesManager.php
New file
@@ -0,0 +1,576 @@
<?php
namespace JVBase\managers;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class FavouritesManager
{
    private Cache $cache;
    protected CustomTable $favourites;
    protected CustomTable $lists;
    protected CustomTable $listItems;
    protected CustomTable $listShares;
    public function __construct()
    {
        $this->defineTables();
    }
    protected function defineTables():void
    {
        $this->defineFavouriteTable();
        $this->defineListTable();
        $this->defineListItemsTable();
        $this->defineListSharesTable();
    }
        private function defineFavouriteTable():void
        {
            $table = CustomTable::for('favourites');
            $table->setColumns([
                'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'user_id'   => "{$table->getUserIDType()} NOT NULL",
                'type'      => 'varchar(50) NOT NULL',                  //As in, post type/user/taxonomy
                'target_id' => 'bigint(20) unsigned NOT NULL',
                'notes'     => 'text DEFAULT NULL',
                'created_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP',
                'updated_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key' => 'UNIQUE', 'value' => '`unique_favourite` (`user_id`, `type`, `target_id`)'],
                '`user_type` (`user_id`, `type`)',
                '`target_type` (`target_id`, `type`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}favourites_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->favourites = $table;
        }
        private function defineListTable():void
        {
            $table = CustomTable::for('favourites_lists');
            $table->setColumns([
                'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'user_id'   => "{$table->getUserIDType()} NOT NULL",
                'name'      => 'varchar(255) NOT NULL',
                'description'=> 'text',
                'notes'     => 'JSON DEFAULT (\'{}\')',
                'created_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
                'updated_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                '`user_lists` (`user_id`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}favourites_list_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->lists = $table;
        }
        private function defineListItemsTable():void
        {
            $table = CustomTable::for('favourites_list_items');
            $table->setColumns([
                'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'user_id'   => "{$table->getUserIDType()} NOT NULL",
                'list_id'   => 'bigint(20) unsigned NOT NULL',
                'item_type' => 'varchar(50) NOT NULL',
                'target_id' => 'bigint(20) unsigned NOT NULL',
                'favourite_id'=> 'bigint(20) unsigned NOT NULL',
                'notes'     => 'JSON DEFAULT (\'{}\')',
                'added_at'  => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key' => 'UNIQUE', 'value' => '`unique_list_item` (`list_id`, `item_type`, `target_id`)'],
                '`list_items` (`list_id`)',
                '`favourite_id` (`favourite_id`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}favourites_list_items_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}favourite_list_items` FOREIGN KEY (`list_id`)
                REFERENCES `{$this->lists->getFullTableName()}` (`id`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}list_favourite` FOREIGN KEY (`favourite_id`)
                REFERENCES `{$this->favourites->getFullTableName()}` (`id`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->listItems = $table;
        }
        private function defineListSharesTable():void
        {
            $table = CustomTable::for('favourites_list_shares');
            $table->setColumns([
                'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'list_id'       => 'bigint(20) unsigned NOT NULL',
                'user_id'       => "{$table->getUserIDType()} NOT NULL",
                'email'         => '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',
                'updated_at'    => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key'  => 'UNIQUE', 'value' => '`unique_share_user` (`list_id`, `user_id`)'],
                ['key'=> 'UNIQUE', 'value' => '`unique_share_email` (`list_id`, `email`'],
                '`list_shares` (`list_id`)',
                '`list_user` (`list_id`, `user_id`)',
                '`status_index` (`status`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}favourites_share_list` FOREIGN KEY (`list_id`)
                REFERENCES `{$this->lists->getFullTableName()}` (`id`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}favourites_share_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->listShares = $table;
        }
    public function toggleFavourite(bool $favourite, int $user_id, int $target_id, string $type):bool
    {
        if (!get_userdata($user_id)) {
            error_log('[FAVOURITES]::toggleFavourite Invalid user '.$user_id);
            return false;
        }
        $registrar = Registrar::getInstance($type);
        if (!$registrar) {
            error_log('[FAVOURITES]::toggleFavourite Invalid type: '.print_r($type, true));
            return false;
        }
        $type = jvbCheckBase($type);
        $registerType = $registrar->getType();
        $test = false;
        switch ($registerType) {
            case 'post':
                $test = get_post($target_id);
                break;
            case 'term':
                $test = get_term($target_id, $type);
                break;
            case 'user':
                $test = get_userdata($target_id);
                break;
        }
        if (!$test || is_wp_error($test)) {
            error_log('[FAVOURITES]::toggleFavourite Could not find target: '.$target_id);
            return false;
        }
        $owner = null;
        if ($registrar->getType() === 'post') {
            $post =  get_post($target_id);
            $owner = $post->post_author;
        } elseif ($registrar->hasFeature('is_ownable')) {
            $term = get_term($target_id, jvbCheckBase($type));
            $owner = jvbGetTermOwners($target_id);
        }
        if ($favourite) {
            $result = $this->favourites->findOrCreate(
                [
                    'user_id'   => $user_id,
                    'target_id' => $target_id,
                    'type'      => $type
                ],
            );
            if ($owner) {
                JVB()->notification()->addNotification(
                    $owner,
                    'new_favourite',
                    $user_id,
                    '',
                    $target_id,
                    $type
                );
            }
        } else {
            $result = $this->favourites->delete([
                'user_id'   => $user_id,
                'target_id' => $target_id,
                'type'      => $type
            ]);
            if (in_array($registerType, ['term', 'user'])) {
                JVB()->notification()->deleteUserPreference($user_id, $target_id, $type);
            }
            if ($owner) {
                JVB()->notification()->removeNotification(
                    $owner,
                    [
                        'from_user' => $user_id,
                        'target_id' => $target_id,
                        'target_type' => $type
                    ]
                );
            }
        }
        return (bool)$result;
    }
        public function favourite(int $user_id, int $target_id, string $type):bool
        {
            return $this->toggleFavourite(true, $user_id, $target_id, $type);
        }
        public function unfavourite(int $user_id, int $target_id, string $type):bool
        {
            return $this->toggleFavourite(false, $user_id, $target_id, $type);
        }
        public function addNoteFromTarget(int $user_id, int $target_id, string $type, string $note):bool
        {
            $favourite = $this->favourites->get([
                'user_id'   => $user_id,
                'target_id' => $target_id,
                'type'      => $type
            ]);
            if (!$favourite) {
                return false;
            }
            return $this->addNote($user_id, $favourite->id, $note);
        }
        public function addNote(int $user_id, int $favourite_id, string $note):bool
        {
            $favourite = $this->favourites->get(['id' => $favourite_id]);
            if (!$favourite) {
                return false;
            }
            if ($favourite->user_id !== $user_id) {
                return false;
            }
            return (bool) $this->favourites->update([
                'note'  => sanitize_textarea_field($note)
            ], [
                'favourite_id' => $favourite_id
            ]);
        }
        public function deleteNote(int $user_id, int $favourite_id):bool
        {
            if (!$this->userCanEditFavourite($user_id, ['id' => $favourite_id])) {
                return false;
            }
            return (bool) $this->favourites->update([
                'note'  => '',
            ], [
                'favourite_id' => $favourite_id
            ]);
        }
        public function addListItemNote(int $user_id, int $listItemId, string|array $note):bool
        {
            if (!$this->userCanEditListItem($user_id, $listItemId)) {
                return false;
            }
            $listItem = $this->listItems->get(['id' => $listItemId]);
            $notes = json_decode($listItem->notes, true);
            $notes = $this->addNoteToArray($notes, $user_id, $note);
            return $this->listItems->update(['id' => $listItemId], ['notes' => json_encode($notes)]);
        }
        public function removeListItemNote(int $user_id, int $listItemId, string $noteID):bool
        {
            if (!$this->userCanEditListItem($user_id, $listItemId)) {
                return false;
            }
            $listItem = $this->listItems->get(['id' => $listItemId]);
            $notes = json_decode($listItem->notes, true);
            $index = array_search($noteID, array_column($notes, 'id'));
            if ($index === false) {
                return false;
            }
            $note = $notes[$index];
            if ($user_id !== $note['user_id']) {
                return false;
            }
            $notes = $this->removeNoteFromArray($notes, $user_id, $noteID);
            return $this->listItems->update(['id' => $listItemId], ['notes' => json_encode($notes)]);
        }
        public function addListNote(int $user_id, int $listId, string $note):bool
        {
            if (!$this->userCanEditList($user_id, $listId)) {
                return false;
            }
            $list = $this->listItems->get(['id' => $listId]);
            $notes = json_decode($list->notes, true);
            $notes = $this->addNoteToArray($notes, $user_id, $note);
            return $this->listItems->update(['id' => $listId], ['notes' => json_encode($notes)]);
        }
        public function removeListNote(int $user_id, int $listId, string $noteID):bool
        {
            if (!$this->userCanEditList($user_id, $listId)) {
                return false;
            }
            $list = $this->lists->get(['id' => $listId]);
            $notes = json_decode($list->notes, true);
            $index = array_search($noteID, array_column($notes, 'id'));
            if ($index === false) {
                return false;
            }
            $note = $notes[$index];
            if ($user_id !== $note['user_id']) {
                return false;
            }
            $notes = $this->removeNoteFromArray($notes, $user_id, $noteID);
            return $this->lists->update(['id' => $listId], ['notes' => json_encode($notes)]);
        }
        public function shareList(int $user_id, int $listId, bool $viewOnly = true, ?int $shareWithUser = null, ?array $newUser = null):bool
        {
            $list = $this->lists->get(['id' => $listId]);
            if (!$list) {
                return false;
            }
            if ($list->user_id !== $user_id) {
                return false;
            }
            if (!$shareWithUser && (empty($newUser) || !array_key_exists('email', $newUser) || !array_key_exists('name', $newUser))) {
                return false;
            }
            $args = [];
            if ($shareWithUser) {
                $args['user_id'] = $shareWithUser;
                JVB()->notification()->addNotification($shareWithUser, 'list_shared', $user_id, '', $listId);
            } elseif ($newUser) {
                $email = sanitize_email($newUser['email']);
                $name = sanitize_text_field($newUser['name']);
                $args['email'] = $email;
                //TODO: Magic Link setup
            }
            if (empty($args)) {
                return false;
            }
            return (bool) $this->listShares->findOrCreate(array_merge($args, ['list_id' => $listId, 'permission' => $viewOnly ? 'view' : 'edit']));
        }
        public function unshareList(int $user_id, int $listId, int|string $userIDorEmail):bool
        {
            $list = $this->lists->get(['id' => $listId]);
            if (!$list) {
                return false;
            }
            if ($list->user_id !== $user_id) {
                return false;
            }
            $where = [
                'list_id'   => $listId
            ];
            if (is_int($userIDorEmail)) {
                $where['user_id'] = $userIDorEmail;
                $notification = JVB()->notification()->table();
                $n = $notification->get([
                    'for_user' => $userIDorEmail,
                    'from_user' => $user_id,
                    'type' => 'list_shared',
                    'target_id' => $listId,
                ]);
                if ($n) {
                    $notification->delete([
                        'for_user' => $userIDorEmail,
                        'from_user' => $user_id,
                        'type' => 'list_shared',
                        'target_id' => $listId,
                    ]);
                }
            } else if (is_email($userIDorEmail)) {
                $where['email'] = sanitize_email($userIDorEmail);
            } else {
                return false;
            }
            return $this->listShares->update($where, ['status' => 'revoked']);
        }
        public function acceptListShare(int $listId, int|string $userIDorEmail):bool
        {
            return $this->respondToShare(true, $listId, $userIDorEmail);
        }
        public function rejectListShare(int $listId, int|string $userIDorEmail):bool
        {
            return $this->respondToShare(false, $listId, $userIDorEmail);
        }
        public function respondToShare(bool $accept, int $listId, int|string $userIDorEmail):bool
        {
            $args =  [
                'listId' => $listId
            ];
            if (is_int($userIDorEmail)) {
                $args['user_id'] = $userIDorEmail;
                $list = $this->lists->get(['id' => $listId]);
                if ($list) {
                    $user = get_userdata($userIDorEmail);
                    $message = $accept ? 'accepted' : 'rejected';
                    $message = $user->first_name . ' ' . $message . ' your list share invitation.';
                    JVB()->notification()->addNotification($list->user_id, 'list_share_status', $userIDorEmail, $message);
                }
            }else if (is_email($userIDorEmail)) {
                $args['email'] = sanitize_email($userIDorEmail);
            } else {
                return  false;
            }
            $share = $this->listShares->get($args);
            if (!$share) {
                return false;
            }
            return $this->listShares->update([
                'status'    => $accept ? 'accepted' : 'rejected',
            ], $args);
        }
    /***************************************************************
     * UTILITY METHODS
     **************************************************************/
    /**
     * @param int $user_id
     * @param array $args
     * @return bool
     */
    public function userCanEditFavourite(int $user_id, array $args):bool
    {
        if (array_key_exists('id', $args)) {
            $where = [
                'id'    => $args['id']
            ];
        } else {
            if (!array_key_exists('target_id', $args)
            || !array_key_exists('type', $args)) {
                error_log('[Favourites]::userCanEditFavourite missing required target_id or type');
                return false;
            }
            $where = [
                'target_id' => absint($args['target_id']),
                'type'      => $args['type']
            ];
        }
        $get = $this->favourites->get($where);
        return !is_null($get) && $get->user_id === $user_id;
    }
    public function userCanViewList(int $user_id, int $list_id):bool
    {
        $list = $this->lists->get(['id' => $list_id]);
        if (!$list) {
            return false;
        }
        if ($list->user_id === $user_id) {
            return true;
        }
        $share = $this->listShares->get(['user_id' => $user_id, 'list_id' => $list_id]);
        return !is_null($share);
    }
    public function userCanEditList(int $user_id, int $list_id):bool
    {
        $list = $this->lists->get(['id' => $list_id]);
        if (!$list) {
            return false;
        }
        if ($list->user_id === $user_id) {
            return true;
        }
        $share = $this->listShares->get(['user_id' => $user_id, 'list_id' => $list_id]);
        if (!$share) return false;
        return $share->permission === 'edit';
    }
    public function userCanEditListItem(int $user_id, int $list_item_id):bool
    {
        $item = $this->listItems->get(['id' => $list_item_id]);
        if (!$item) {
            return false;
        }
        if ($item->user_id === $user_id) {
            return true;
        }
        return $this->userCanEditList($user_id, $item->list_id);
    }
    protected function addNoteToArray(array $notes, int $user_id, string|array $note):array
    {
        $updated = date('Y-m-d H:i:s');
        if (is_string($note)) {
            //It is a new note
            $notes[] = [
                'id'    => uniqid(),
                'note' => sanitize_textarea_field($note),
                'user_id' => $user_id,
                'updated' => $updated
            ];
        } else {
            $index = array_search($note['id'], array_column($notes, 'id'));
            if ($index === false) {
                //Shouldn't happen, but just in case: add it to the array
                $notes[] = [
                    'id'    => sanitize_text_field($note['id']),
                    'note'  => sanitize_textarea_field($note['note']),
                    'user_id' => $user_id,
                    'updated' => $updated
                ];
            } else {
                $notes[$index]['note'] = sanitize_textarea_field($note['note']);
                $notes[$index]['updated'] = $updated;
            }
        }
        return $notes;
    }
    protected function removeNoteFromArray(array $notes, int $user_id, string $noteID):array
    {
        $index = array_search($noteID, array_column($notes, 'id'));
        if ($index === false) {
            return $notes;
        }
        $note = $notes[$index];
        if ($user_id !== $note['user_id']) {
            return $notes;
        }
        unset($notes[$index]);
        return array_values($notes); //reindexed array
    }
}
inc/managers/ForumManager.php
New file
@@ -0,0 +1,99 @@
<?php
namespace JVBase\managers;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Forum Manager
 * TODO
 * Handles a forum functionality
 */
class ForumManager
{
    protected CustomTable $relationships;
    protected CustomTable $forum;
    protected KarmaManager $karma;
    public function __construct()
    {
        $this->defineTables();
        $this->registerHooks();
    }
    public function registerHooks():void
    {
        add_action('jvbDefineRegistrar', [$this, 'registerForum']);
        add_action('jvbDefineRegistrarFields', [$this, 'registerForumFields']);
        add_action('plugins_loaded', [$this, 'registerForum'], 1);
        add_action('plugins_loaded', [$this, 'registerForum'], 2);
    }
        public function registerForum():void
        {
            Registrar::forPost('jvbforum', 'Post', 'Posts')
                ->setIcon('note')
                ->make([
                    'public'    => false,
                    'taxonomies' => [
                        'topic'
                    ]
                ]);
            Registrar::forTerm('jvbtopic', 'Topic', 'Topics')
                ->setIcon('folder')
                ->make([
                    'hierarchical' => true,
                    'for'   => ['jvbforum']
                ]);
        }
        public function registerForumFields():void
        {
            $forum = Registrar::getInstance('jvbforum');
            $fields = $forum->fields();
            $topic = Registrar::getInstance('jvbtopic');
            $topicFields = $topic->fields();
        }
    protected function defineTables():void
    {
        $this->definedRelationshipsTable();
        $this->karma = KarmaManager::for('forum', 'post');
    }
        protected function definedRelationshipsTable():void
        {
            $table = CustomTable::for('forum_relationship');
            $table->setColumns([
                'id'    => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'term_id'   => "{$table->getTermIDType()} NOT NULL",
                'user_id'   => "{$table->getUserIDType()} NOT NULL",
                'profile_id'=> "{$table->getPostIDType()} NOT NULL",
                'forum_count'   => 'int(10) unsigned NOT NULL',
                'last_post_date'=> 'datetime DEFAULT NULL',
                'updated_at'    => 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key' => 'UNIQUE', 'value' => '`term_user` (`term_id`, `user_id`)'],
                '`term_id` (`term_id`)',
                '`user_id` (`user_id`)',
                '`profile_id` (`profile_id`)',
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}fr_term_forum_term` FOREIGN KEY (`term_id`)
                REFERENCES `{$table->getTermTable()}` (`term_id`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}fr_term_forum_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}fr_term_news_profile` FOREIGN KEY (`profile_id`)
                REFERENCES `{$table->getPostTable()}` (`ID`) ON DELETE CASCADE",
            ]);
            $table->defineTable();
            $this->relationships = $table;
        }
}
inc/managers/InvitationsManager.php
@@ -20,9 +20,12 @@
    protected Cache $cache;
    public function __construct()
    {
        $this->defineTable();
        if (!isset($this->table)) {
            return;
        }
        $this->setInviteConfig();
        $this->cache = Cache::for('invitations');
        $this->table = CustomTable::for('invitations');
        add_action('init', [$this, 'registerInvitationExecutors'], 5);
        add_action('user_register', [$this, 'checkInvitation']);
@@ -31,6 +34,60 @@
        add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
    }
    public function defineTable():void
    {
        $terms = Registrar::getFeatured('invitable', 'term');
        $roles = Features::forMembership()->get('can_invite');
        if (empty($terms) && empty($roles)) {
            return;
        }
        $table = CustomTable::for('invitations');
        $columns = [
            'id'                => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'name'              => 'varchar(255) NOT NULL',
            'email'             => 'varchar(255) NOT NULL',
            'invitation_token'  => 'varchar(255) NOT NULL',
            'invited_role'      => 'varchar(50) NOT NULL COMMENT \'Role being invited to\'',
            'status'            => 'ENUM(\'pending\',\'accepted\',\'rejected\',\'expired\',\'revoked\') DEFAULT \'pending\'',
            'inviters'          => 'JSON NOT NULL COMMENT \'Array of {user_id, invited_at}\'',
            'new_user_id'       => $table->getUserIDType().' DEFAULT NULL',
            'expires_at'        => 'datetime NOT NULL',
            'accepted_at'       => 'datetime DEFAULT NULL',
            'created_at'        => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
            'updated_at'        => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
        ];
        foreach ($terms as $tax) {
            $columns["to_{$tax}"] = $table->getTermIDType().' DEFAULT NULL';
        }
        $table->setColumns($columns);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE KEY', 'value' => '`unique_email_role` (`email`, `invited_role`)'],
            '`token_lookup` (`invitation_token`)',
            '`status_expiry` (`status`, `expires_at`)',
            '`role_status` (`invited_role`, `status`)',
            '`email_status` (`email`, `status`)'
        ]);
        $constraints = [];
        $base = BASE;
        global $wpdb;
        foreach ($terms as $tax) {
            $constraints[] = "CONSTRAINT `{$base}invitations_{$tax}_fk` FOREIGN KEY (`to_{$tax}`) REFERENCES `{$wpdb->terms}` (`term_id`)
            ON DELETE SET NULL";
        }
        $constraints[] = "CONSTRAINT `{$base}invitations_user_fk`  FOREIGN KEY (`new_user_id`) REFERENCES `{$table->getUserTable()}` (`ID`)
        ON DELETE SET NULL";
        $table->setConstraints($constraints);
        $table->defineTable();
        $this->table = $table;
    }
    protected function setInviteConfig():void
    {
        $this->inviteConfig = get_option(BASE.'invitation_config', [
inc/managers/KarmaManager.php
New file
@@ -0,0 +1,342 @@
<?php
namespace JVBase\managers;
use Exception;
use WP_Error;
use WP_Post;
use WP_Term;
use WP_User;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Response Manager
 *
 * Handles a responses
 *
 */
class KarmaManager
{
    protected static array $instances;
    protected string $key;
    protected string $references;
    protected CustomTable $table;
    protected static Cache $cache;
    private function __construct(string $key, ?string $references = null)
    {
        $this->key = $key;
        if (is_null($references)) {
            $references = match($key){
                'post'  => 'post',
                'term'  => 'term',
                'user'  => 'user',
                default => false
            };
            if (!$references) {
                error_log('[KarmaManager]::__construct No references set, and no default found. Could not create instance');
                unset(self::$instances[$key]);
                return;
            }
        }
        $this->references = $references;
        $test = $this->defineTable();
        if (!$test) {
            unset(self::$instances[$key]);
        }
//      $this->cache = Cache::for($this->references.'_karma');
//      switch ($this->references) {
//          case 'post':
//              $this->cache->connect('post');
//              break;
//          case 'term':
//              $this->cache->connect('term');
//              break;
//          case 'user':
//              $this->cache->connect('user');
//              break;
//      }
        if (!isset(self::$cache)) {
            self::$cache = Cache::for('user_karma')->connect('user');
        }
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
    }
    public static function getInstance(string $key):KarmaManager|false
    {
        if (!isset(self::$instances[$key])) {
            return false;
        }
        return self::$instances[$key];
    }
    public static function for(string $key, ?string $references = null): KarmaManager
    {
        $key = sanitize_key($key);
        if (!isset(self::$instances[$key])) {
            self::$instances[$key] = new KarmaManager($key, $references);
        }
        return self::$instances[$key];
    }
    protected function getReferenceTable(CustomTable $table):array
    {
        switch ($this->references) {
            case 'post':
            case 'posts':
                return [
                    $table->getPostIDType(),
                    $table->getPostTable(),
                    'ID'
                ];
            case 'term':
            case 'terms':
                return [
                    $table->getTermIDType(),
                    $table->getTermTable(),
                    'term_id'
                ];
            case 'user':
            case 'users':
                return [
                    $table->getUserIDType(),
                    $table->getUserTable(),
                    'ID'
                ];
        }
        //If we get here, it is a custom table.
        $custom = CustomTable::getInstance($this->references);
        if (!$custom) {
            return [false,false,false];
        }
        return [
            'bigint(20)',
            $custom->getFullTableName(),
            'id'
        ];
    }
    protected function defineTable():bool
    {
        $table = CustomTable::for('karma_'.$this->key);
        [$type, $table, $column] = $this->getReferenceTable($table);
        if (!$type) {
            error_log('[KarmaManager]::defineTable Attempted to build reference for invalid table: '.$this->references);
            CustomTable::destroyInstance('karma_'.$this->key);
            return false;
        }
        $table->setColumns([
            'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'item_id'   => "{$type} NOT NULL",
            'user_id'   => "{$table->getUserIDType()} NOT NULL",
            'content'   => 'varchar(255) NOT NULL',
            'vote'      => "ENUM('up,'down') NOT NULL",
            'created_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
            'updated_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => 'id'],
            ['key' => 'UNIQUE', 'value' => '`user_item` (`user_id`, `item_id`, `content`)'],
            '`item_vote` (`item_id`,`content`,`vote`)',
            '`item_id` (`item_id`)',
            '`user_id` (`user_id`)'
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}kt_{$type}_item_id` FOREIGN KEY (`item_id`)
            REFERENCES `{$table}` (`{$column}`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}kt_{$type}_user_id` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
        ]);
        $table->defineTable();
        $this->table = $table;
        return true;
    }
    protected function response(?bool $success = null, string $message =''):array
    {
        $response = [
            'success'   => is_null($success) ? 'partial' : $success,
        ];
        if (!empty($message)) {
            $response['message'] = $message;
        }
        return $response;
    }
    protected function getItem(int $itemID, string $content):object|false
    {
        $based = jvbCheckBase($content);
        return match($this->references) {
            'post'  => get_post($itemID),
            'term'  => get_term($itemID, $based),
            'user'  => get_userdata($itemID),
            'response' => JVB()->responses()->response->get(['id' => $itemID])??false,
            default => false    //TODO if we do have custom tables, we'll have to set that up here
        };
    }
    public function vote(int $userID, int $itemID, string $content, ?bool $vote = null):array
    {
        $content = jvbNoBase($content);
        $item = $this->getItem($itemID, $content);
        if (!$item || is_wp_error($item)) {
            return $this->response(false, 'Could not find target');
        }
        $message = '';
        //removing record
        if (is_null($vote)){
            $success = $this->table->delete([
                'user_id'   => $userID,
                'item_id'   => $itemID,
                'content'   => $content
            ]);
            if ($success) {
                $message = 'Vote removed successfully';
            }
        } else {
            $success = $this->table->findOrCreate(
                [
                    'user_id'   => $userID,
                    'item_id'   => $itemID,
                    'content'   => $content
                ],
                [
                    'vote'  => $vote ? 'up' : 'down'
                ]
            );
            if (!$success) {
                $message = 'Could not cast your vote';
            } else {
                $message = 'Vote cast successfully';
            }
        }
        self::$cache->forget($userID);
        JVB()->queue()->queueOperation(
            'karmic',
            0,
            [
                'items' => [
                    'itemID'    => $itemID,
                    'content'   => $content,
                ],
                'type'      => $this->references,
            ],
            [
                'chunk_key' => 'items',
            ]
        );
        return $this->response((bool) $success, $message);
    }
    public function getCount(int $itemID, string $content):array
    {
        $upvotes = count($this->table->pluck(
            'id',
        [
            'item_id'   => $itemID,
            'content'   => $content,
            'vote'      => 'up'
        ]));
        $downvotes = count($this->table->pluck(
            'id',
            [
                'item_id'   => $itemID,
                'content'   => $content,
                'vote'      => 'down'
            ]
        ));
        return [
            'upvotes'   => $upvotes,
            'downvotes' => $downvotes,
            'karmic_score'  => $upvotes - $downvotes
        ];
    }
    public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
    {
        if ($operation->type !== 'karmic') {
            return $result;
        }
        if ($data['type'] !== $this->references) {
            return $result;
        }
        $results = [];
        foreach ($data['items'] as $item) {
            [$upvotes, $downvotes, $karma] = $this->getCount($item['itemID'], $item['content']);
            try {
                switch ($this->references) {
                    case 'post':
                        update_post_meta($item['itemID'], BASE.'upvotes', $upvotes);
                        update_post_meta($item['itemID'], BASE.'downvotes', $downvotes);
                        update_post_meta($item['itemID'], BASE.'karma', $karma);
                        break;
                    case 'term':
                        update_term_meta($item['itemID'], BASE.'upvotes', $upvotes);
                        update_term_meta($item['itemID'], BASE.'downvotes', $downvotes);
                        update_term_meta($item['itemID'], BASE.'karma', $karma);
                        break;
                    case 'user':
                        update_user_meta($item['itemID'], BASE.'upvotes', $upvotes);
                        update_user_meta($item['itemID'], BASE.'downvotes', $downvotes);
                        update_user_meta($item['itemID'], BASE.'karma', $karma);
                        break;
                    case 'response':
                        JVB()->responses()->response->update(
                            [
                                'upvotes'   => $upvotes,
                                'downvotes' => $downvotes,
                                'karma'     => $karma
                            ],
                            [
                                'id'    => $item['itemID']
                            ]
                        );
                        break;
                }
                $results[$item['itemID']] = true;
            } catch (Exception $e) {
                $results[$item['itemID']] = false;
            }
        }
        return [
            'success'   => true,
            'result'    => $results
        ];
    }
    public static function getUserVotes(int $userID):array
    {
        return self::$cache->remember(
            $userID,
            function() use($userID) {
                $votes = [];
                foreach (self::$instances as $instance) {
                    $results = $instance->table->getMany([
                        'where' => [
                            'user_id'   => $userID
                        ]
                    ]);
                    foreach ($results as $result) {
                        if (!array_key_exists($result['content'], $votes)) {
                            $votes[$result['content']] = [];
                        }
                        $votes[$result['content']][$result['item_id']] = $result['vote'];
                    }
                }
                return $votes;
            }
        );
    }
}
inc/managers/NotificationManager.php
@@ -1,17 +1,14 @@
<?php
namespace JVBase\managers;
use JVBase\JVB;
use JVBase\registrar\Registrar;
use WP_Error;
use Exception;
use WP_Post;
use WP_User;
use JVBase\managers\Notifications\Content;
use JVBase\managers\Notifications\EmailDigests;
use JVBase\managers\Notifications\Notifications;
use JVBase\managers\Notifications\Preferences;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
//TODO: Ensure this works with the constants setup
/**
 * NotificationManager - Centralized notification system for edmonton.ink
 *
@@ -21,1747 +18,77 @@
 */
class NotificationManager
{
    protected Cache $userCache; //the individual notifications
    protected Cache $contentCache;  //the 'shared' notifications on new content that has been created
    protected Cache $artistsCache;
    protected Cache $favouritesCache;
    protected Cache $followerCache;
    protected string $campaign;
    protected string $table = BASE.'notifications';
    protected string $contentTable = BASE.'notifications_content';
    protected string $seenTable = BASE.'notifications_user_seen';
    protected string $preferencesTable = BASE.'notification_preferences';
    protected array $notification_types = [
        // System notifications
        'new_favourite'     => [
            'icon'            => 'heart',
            'priority'        => 'low',
            'requires_action' => false,
            'audience'        => 'content_owner',
            'email_digest'    => true
        ],
        'artist_approved'   => [
            'icon'            => 'check',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'artist_joined'     => [
            'icon'              => 'artist',
            'priority'          => 'low',
            'requires_action'   => false,
            'email_digest'      => false,
        ],
        'artist_rejected'   => [
            'icon'            => 'x',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'artist_invitation' => [
            'icon'            => 'invite',
            'priority'        => 'high',
            'requires_action' => true,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'artist_request'    => [
            'icon'            => 'artist',
            'priority'        => 'high',
            'requires_action' => true,
            'audience'        => 'single',
            'email_digest'    => true,
        ],
        'shop_accepted'     => [
            'icon'            => 'shop',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true,
        ],
        'shop_rejected'     => [
            'icon'            => 'shop',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true,
        ],
        'new_term'          => [
            'icon'            => 'style',
            'priority'        => 'medium',
            'requires_action' => true,
            'audience'        => 'artists',
            'email_digest'    => false
        ],
        'term_approved'     => [
            'icon'            => 'check',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'term_rejected'     => [
            'icon'            => 'x',
            'priority'        => 'medium',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'list_shared'       => [
            'icon'            => 'share',
            'priority'        => 'medium',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'list_share_accepted' => [
            'icon'            => 'check',
            'priority'        => 'low',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'list_share_revoked' => [
            'icon'            => 'close',
            'priority'        => 'medium',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'system_message'    => [
            'icon'            => 'info',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'all',
            'email_digest'    => false
        ]
    ];
   protected Notifications $notifications;
   protected Content $content;
   protected EmailDigests $digest;
   protected Preferences $preferences;
    /**
     * Constructor
     */
    public function __construct()
    {
        $this->userCache = Cache::for('userNotifications', WEEK_IN_SECONDS);
        $this->contentCache = Cache::for('contentNotifications', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
        $this->artistsCache = Cache::for('artist', WEEK_IN_SECONDS)->connect('post');
        $this->favouritesCache = Cache::for('favouritedUsers', WEEK_IN_SECONDS)->connect('favourites');
        $this->followerCache = Cache::for('totalFollowers', WEEK_IN_SECONDS)->connect('favourites');
   public function __construct()
   {
       $this->notifications = new Notifications();
       $this->content = new Content();
       $this->digest = new EmailDigests();
       $this->preferences = new Preferences();
   }
        // Add filter for bulk operation handling
        add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
        add_action(BASE . 'cleanup_notifications', [$this, 'cleanupOldNotifications']);
        // Track content creation for notifications
        add_action('save_post', [ $this, 'trackContentCreation' ], 10, 3);
        add_action('saved_term', [ $this, 'track_shop_creation' ], 10, 3);
        // Generate content summaries when users log in
        add_action('wp_login', [ $this, 'generateUserContentNotifications' ], 10, 2);
        // Register digest cron jobs
        $this->registerCron();
    }
    /**
     * Registers the digest cron jobs
     * @return void
     */
    protected function registerCron():void
    {
        add_action(BASE . 'notification_digest_daily', [ $this, 'runDailyDigests' ]);
        add_action(BASE . 'notification_digest_weekly', [ $this, 'runWeeklyDigests' ]);
        add_action(BASE . 'notification_digest_monthly', [ $this, 'runMonthlyDigests' ]);
    }
    /************************************************************
     * Basic Notification Methods
     ************************************************************/
    /**
     * @return array
     */
    public function getNotificationTypes():array
    {
        return $this->notification_types;
    }
    /**
     * Add a new notification
     *
     * @param int|array $user_ids Recipient user ID(s)
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function addNotification(
        mixed $user_ids,
        string $type,
        int|null $action_user_id = null,
        string $message = '',
        int|null $target_id = null,
        string|null $target_type = null,
        array|null $context = null
    ):bool|WP_Error {
        // Validate notification type
        if (!isset($this->notification_types[$type])) {
            $this->logError("Invalid notification type: $type", [
                'user_ids' => is_array($user_ids) ? implode(',', $user_ids) : $user_ids,
                'message' => $message
            ], 'warning');
            return new WP_Error('invalid_type', 'Invalid notification type');
        }
        // Handle single user or array of users
        $user_ids = is_array($user_ids) ? $user_ids : [$user_ids];
        if (empty($user_ids)) {
            return false;
        }
        // Queue the bulk notification operation
        $queue = JVB()->queue();
        $queue->queueOperation(
            'addNotification',
            $action_user_id,
            [
                'user_ids' => $user_ids,
                'type' => $type,
                'action_user_id' => $action_user_id,
                'message' => $message,
                'target_id' => $target_id,
                'target_type' => $target_type,
                'context' => $context
            ],
            [
                'count' => count($user_ids),
                'priority' => 'normal',
                'chunk_key' => 'user_ids',
                'chunk_size'=> 50,
                'operation_id' => 'notifications' . uniqid()
            ]
        );
        return true;
    }
    /**
     * Process notification operation
     *
     * @param int|array $user_ids Recipient user ID(s)
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function processNotification(
        int|array $user_ids,
        string $type,
        int|null $action_user_id = null,
        string $message = '',
        int|null $target_id = null,
        string|null $target_type = null,
        array|null $context = null
    ):bool|WP_Error {
        $config = $this->notification_types[$type];
        $notifications = [];
        $errors = [];
        $user_ids = is_array($user_ids) ? $user_ids : [$user_ids];
        global $wpdb;
        foreach ($user_ids as $user_id) {
            // Skip invalid users
            if (!$user_id || $user_id <= 0) {
                $errors[] = "Invalid user ID: $user_id";
                continue;
            }
            // Skip sending notifications to self if action_user_id == user_id
            if ($action_user_id && $action_user_id == $user_id && !($config['notify_self'] ?? false)) {
                continue;
            }
            try {
                // Prepare context data
                $context_json = null;
                if (!empty($context)) {
                    $context_json = json_encode($context);
                }
                // Insert new notification
                $result = $wpdb->insert(
                    $wpdb->prefix . $this->table,
                    [
                        'owner_id' => $user_id,
                        'action_user_id' => $action_user_id,
                        'type' => $type,
                        'status' => 'unread',
                        'message' => $message,
                        'priority' => $config['priority'] ?? 'normal',
                        'target_id' => $target_id,
                        'target_type' => $target_type,
                        'context' => $context_json,
                        'requires_action' => !empty($config['requires_action']) ? 1 : 0,
                        'created_at' => current_time('mysql'),
                        'updated_at' => current_time('mysql')
                    ]
                );
                if ($result) {
                    $notification_id = $wpdb->insert_id;
                    $notifications[] = $notification_id;
                    // Clear cache for this user
                    $this->clearNotificationCache($user_id);
                } else {
                    $errors[] = "Database error for user $user_id: " . $wpdb->last_error;
                    $this->logError("Failed to insert notification", [
                        'user_id' => $user_id,
                        'type' => $type,
                        'db_error' => $wpdb->last_error
                    ]);
                }
            } catch (Exception $e) {
                $errors[] = "Exception for user $user_id: " . $e->getMessage();
                $this->logError("Error adding notification", [
                    'user_id' => $user_id,
                    'type' => $type,
                    'message' => $message,
                    'error' => $e->getMessage()
                ]);
            }
        }
        // Return results
        if (!empty($notifications) && !empty($errors)) {
            $this->logError("Some notifications failed", [
                'successful' => count($notifications),
                'errors' => $errors
            ], 'warning');
        }
        if (empty($notifications) && !empty($errors)) {
            return new WP_Error('notification_failed', 'All notifications failed', [
                'errors' => $errors
            ]);
        }
        return !empty($notifications);
    }
    /**
     * Wrapper for notifying verified artists
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyVerifiedArtists(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getVerified('artist');
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /**
     * Wrapper for notifying verified partners
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyVerifiedPartners(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getVerified('partner');
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /**
     * Wrapper for notifying verified partners
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyEnthusiasts(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getUserIDs('enthusiast');
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /**
     * Wrapper for notifying verified partners
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyEveryone(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getUserIDs(Registrar::getRegistered('user'));
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /************************************************************
     * Content Notification Methods
     ************************************************************/
    /**
     * Track content creation or updates for notification purposes
     *
     * @param int $post_id Post ID
     * @param WP_Post $post Post object
     * @param bool $update Whether this is an update
     * @return void
     */
    public function trackContentCreation(int $post_id, WP_POST $post, bool $update):void
    {
        // SAFETY: Skip attachments and other non-content post types
        if (in_array($post->post_type, jvbIgnoredPostTypes())) {
            return;
        }
        // Skip if not a published post
        if ($post->post_status !== 'publish') {
            return;
        }
        // Check if this is a relevant content type
        $content_types = array_map(function($type) {return jvbCheckBase($type->getSlug()); }, Registrar::getFeatured('show_feed', 'post'));
        if (!in_array($post->post_type, $content_types)) {
            return;
        }
        // Check if the artist has any followers before tracking
        $follower_count = $this->getFollowerCount($post->post_author);
        if ($follower_count === 0) {
            return; // No need to track if nobody follows this artist
        }
        // Get the clean content type for storing
        $content_type = str_replace(BASE, '', $post->post_type);
        // Update the artist's content notification records
        $this->updateContentNotificationRecord($post->post_author, $content_type, $post_id, $update);
    }
    /**
     * Update content notification record for an artist
     *
     * @param int $artist_id Artist user ID
     * @param string $content_type Content type (tattoo, artwork, etc)
     * @param int $content_id Content post ID
     * @param bool $is_update Whether this is an update to existing content
     *
     * @return bool Success or failure
     */
    protected function updateContentNotificationRecord(int $artist_id, string $content_type, int $content_id, bool $is_update = false):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->contentTable;
        // Start transaction
        $wpdb->query('START TRANSACTION');
        $success = false;
        try {
            // Get today's date
            $today = date('Y-m-d');
            $frequency_updates = [];
            // Update records for each frequency
            foreach (['daily', 'weekly', 'monthly'] as $frequency) {
                // Find or create a record for this artist, date and frequency
                $record = $wpdb->get_row($wpdb->prepare(
                    "SELECT * FROM {$table}
                 WHERE user_id = %d AND date = %s AND frequency = %s",
                    $artist_id,
                    $today,
                    $frequency
                ));
                if ($record) {
                    $frequency_updates[$frequency] = $this->updateExistingContentRecord(
                        $record,
                        $content_type,
                        $content_id,
                        $is_update
                    );
                } else {
                    $frequency_updates[$frequency] = $this->createNewContentRecord(
                        $artist_id,
                        $frequency,
                        $content_type,
                        $content_id,
                        $is_update
                    );
                }
            }
            // Check if all updates were successful
            $success = !in_array(false, $frequency_updates, true);
            if ($success) {
                $wpdb->query('COMMIT');
                return true;
            } else {
                // If any update failed, roll back
                $wpdb->query('ROLLBACK');
                $this->logError("Failed to update content notification records", [
                    'artist_id' => $artist_id,
                    'content_type' => $content_type,
                    'content_id' => $content_id,
                    'updates' => $frequency_updates
                ]);
                return false;
            }
        } catch (Exception $e) {
            $wpdb->query('ROLLBACK');
            $this->logError("Exception in content notification update", [
                'artist_id' => $artist_id,
                'content_type' => $content_type,
                'content_id' => $content_id,
                'error' => $e->getMessage()
            ]);
            return false;
        }
    }
    /**
     * Update an existing content notification record
     *
     * @param object $record Existing database record
     * @param string $content_type Content type
     * @param int $content_id Content ID
     * @param bool $is_update Whether this is an update
     *
     * @return bool Success or failure
     */
    protected function updateExistingContentRecord(object $record, string $content_type, int $content_id, bool $is_update):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->contentTable;
        // Decode existing JSON data
        $new_items     = json_decode($record->new_items, true) ?: [];
        $updated_items = json_decode($record->updated_items, true) ?: [];
        // Initialize arrays if not present
        if (!isset($new_items[BASE.$content_type])) {
            $new_items[BASE.$content_type] = [];
        }
        if (!isset($updated_items[BASE.$content_type])) {
            $updated_items[BASE.$content_type] = [];
        }
        // Add ID to appropriate array
        if ($is_update) {
            // Add to updated array if not already there and not in new items
            if (!in_array($content_id, $updated_items[BASE.$content_type]) &&
                 !in_array($content_id, $new_items[BASE.$content_type])) {
                $updated_items[BASE.$content_type][] = $content_id;
            }
        } else {
            // Add to new array if not already there
            if (!in_array($content_id, $new_items[BASE.$content_type])) {
                $new_items[BASE.$content_type][] = $content_id;
            }
        }
        // Prepare update data
        $update_data = [
            'new_items'     => json_encode($new_items),
            'updated_items' => json_encode($updated_items),
            'updated_at'    => current_time('mysql')
        ];
        // Update appropriate count column
        $count_column = "{$content_type}_count";
        if (property_exists($record, $count_column)) {
            $update_data[ $count_column ] = $record->$count_column + ( $is_update ? 0 : 1 );
        }
        // Update total count
        $update_data['total_items'] = $record->total_items + ( $is_update ? 0 : 1 );
        // Update the record
        return $wpdb->update(
            $table,
            $update_data,
            [ 'id' => $record->id ]
        ) !== false;
    }
    /**
     * Create a new content notification record
     *
     * @param int $artist_id Artist user ID
     * @param string $frequency Frequency (daily, weekly, monthly)
     * @param string $content_type Content type
     * @param int $content_id Content ID
     * @param bool $is_update Whether this is an update
     *
     * @return bool Success or failure
     */
    protected function createNewContentRecord(int $artist_id, string $frequency, string $content_type, int $content_id, bool $is_update):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->contentTable;
        // Initialize arrays for new and updated items
        $new_items     = [];
        $updated_items = [];
        if ($is_update) {
            $updated_items[BASE.$content_type] = [ $content_id ];
        } else {
            $new_items[BASE.$content_type] = [ $content_id ];
        }
        // Prepare insert data
        $insert_data = [
            'user_id'       => $artist_id,
            'date'          => date('Y-m-d'),
            'frequency'     => $frequency,
            'total_items'   => 1,
            'new_items'     => json_encode($new_items),
            'updated_items' => json_encode($updated_items),
            'created_at'    => current_time('mysql'),
            'updated_at'    => current_time('mysql')
        ];
        // Set count column
        $insert_data["{$content_type}_count"] = $is_update ? 0 : 1;
        // Insert the record
        return $wpdb->insert($table, $insert_data) !== false;
    }
    /**
     * Generate content notifications for a user upon login
     *
     * @param string $username Username
     * @param WP_User $user User object
     * @return void
     */
    public function generateUserContentNotifications(string $username, WP_User $user):void
    {
        $this->createContentSeenRecords($user->ID);
    }
    /**
     * Create content seen records for a user's followed artists
     *
     * @param int $user_id User ID
     *
     * @return int Number of notifications created
     */
    protected function createContentSeenRecords(int $user_id):int
    {
        global $wpdb;
        try {
            // Get followed artists
            $followed_artists = $this->getFollowedArtists($user_id);
            if (empty($followed_artists)) {
                return 0;
            }
            // Get the last time notifications were checked
            $last_check = get_user_meta($user_id, BASE . 'last_content_check', true);
            $since_date = $last_check ? date('Y-m-d', strtotime($last_check)) : date('Y-m-d', strtotime('-7 days'));
            // Create placeholders for SQL query
            $placeholders = implode(',', array_fill(0, count($followed_artists), '%d'));
            // Get content notifications since last check
            $content_records = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT * FROM {$wpdb->prefix}{$this->contentTable}
                     WHERE user_id IN ($placeholders)
                     AND date >= %s
                     AND total_items > 0",
                    array_merge($followed_artists, [ $since_date ])
                )
            );
            if (empty($content_records)) {
                return 0;
            }
            // Add user seen records for each content notification
            $count           = 0;
            $user_seen_table = $wpdb->prefix . $this->seenTable;
            foreach ($content_records as $record) {
                // Check if record already exists
                $exists = $wpdb->get_var(
                    $wpdb->prepare(
                        "SELECT id FROM {$user_seen_table}
                         WHERE user_id = %d AND content_notification_id = %d",
                        $user_id,
                        $record->id
                    )
                );
                if (!$exists) {
                    // Create new seen record
                    $wpdb->insert(
                        $user_seen_table,
                        [
                            'user_id'                 => $user_id,
                            'content_notification_id' => $record->id,
                            'status'                  => 'unread',
                            'created_at'              => current_time('mysql')
                        ]
                    );
                    $count ++;
                }
            }
            // Update last check time
            update_user_meta($user_id, BASE . 'last_content_check', current_time('mysql'));
            // Clear cache
            $this->clearNotificationCache($user_id);
            return $count;
        } catch (Exception $e) {
            $this->logError("Error creating content seen records: " . $e->getMessage(), [
                'user_id' => $user_id
            ]);
            return 0;
        }
    }
    /************************************************************
     * Notification Digest Methods
     ************************************************************/
    /**
     * Process daily notification digests
     */
    public function runDailyDigests():void
    {
        $this->campaign = 'daily_digest_' . date('Y-m-d');
        $this->processDigest('daily');
    }
    /**
     * Process weekly notification digests
     */
    public function runWeeklyDigests():void
    {
        $this->campaign = 'weekly_digest_' . date('Y-m-d');
        $this->processDigest('weekly');
    }
    /**
     * Process monthly notification digests
     */
    public function runMonthlyDigests():void
    {
        $this->campaign = 'monthly_digest_' . date('Y-m-d');
        $this->processDigest('monthly');
    }
    /**
     * Process digests for a specific frequency
     *
     * @param string $frequency Digest frequency (daily, weekly, monthly)
     *
     * @return bool Success or failure
     */
    protected function processDigest(string $frequency):bool
    {
        // Get users with this digest frequency preference
        $users = $this->getUsersForDigest($frequency);
        if (empty($users)) {
            return true; // No users to process
        }
        // Queue digest generation
        $queue = JVB()->queue();
        $queue->queueOperation(
            'email_notification_digest',
            0, // System user
            [
                'frequency' => $frequency,
                'users'     => $users
            ],
            [
                'count' => count($users),
                'chunk_key' => 'users',
                'chunk_size' => 20,
                'priority'        => 'normal',
                'operation_id'    => 'notification_digest_' . date('Y_m_d')
            ]
        );
        return true;
    }
    /**
     * Get users who have subscribed to a specific digest frequency
     *
     * @param string $frequency Digest frequency
     *
     * @return array Array of user IDs
     */
    protected function getUsersForDigest(string $frequency):array
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->preferencesTable;
        // Get users with this frequency setting
        $users = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT user_id FROM $table
                 WHERE frequency = %s",
                $frequency
            )
        );
        return $users ?: [];
    }
    /**
     * Generate and send a digest email for a user
     *
     * @param int $user_id User ID
     * @param string $frequency Digest frequency
     *
     * @return bool Success status
     */
    public function generateUserDigest(int $user_id, string $frequency):bool
    {
        try {
            $user = get_userdata($user_id);
            if (!$user || !is_email($user->user_email)) {
                return false;
            }
            // Get date range based on frequency
            $today      = date('Y-m-d');
            $since_date = $this->getSinceDate($frequency, $today);
            // Get regular notifications
            $notifications = $this->getDigestNotifications($user_id);
            // Get content updates from followed artists
            $content_updates = $this->getContentUpdatesForDigest($user_id, $since_date);
            if (empty($notifications) && empty($content_updates)) {
                return true; // Nothing to send
            }
            // Generate and send email
            $sent = $this->sendDigestEmail($user, $frequency, $notifications, $content_updates);
            if ($sent) {
                // Update preferences last_sent timestamp
                $this->updateDigestTimestamps($user_id, $frequency);
                return true;
            }
            return false;
        } catch (Exception $e) {
            $this->logError("Error generating digest for user $user_id: " . $e->getMessage());
            return false;
        }
    }
    /**
     * Get notifications for a digest
     *
     * @param int $user_id User ID
     *
     * @return array Notification objects
     */
    protected function getDigestNotifications(int $user_id):array
    {
        global $wpdb;
        // Get unread, non-emailed notifications
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}{$this->table}
                 WHERE user_id = %d
                 AND status = 'unread'
                 AND emailed_at IS NULL
                 ORDER BY priority DESC, created_at DESC",
                $user_id
            )
        );
    }
    /**
     * Get content updates for digest
     *
     * @param int $user_id User ID
     * @param string $since_date Date string (YYYY-MM-DD)
     *
     * @return array Content update records
     */
    protected function getContentUpdatesForDigest(int $user_id, string $since_date):array
    {
        // Get followed artists
        $followed_artists = $this->getFollowedArtists($user_id);
        if (empty($followed_artists)) {
            return [];
        }
        global $wpdb;
        // Create placeholders for SQL IN clause
        $placeholders = implode(',', array_fill(0, count($followed_artists), '%d'));
        // Get content records since the date
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}{$this->contentTable}
                 WHERE user_id IN ($placeholders)
                 AND date >= %s
                 AND total_items > 0
                 ORDER BY date DESC",
                array_merge($followed_artists, [ $since_date ])
            )
        );
    }
    /**
     * Get since date for a frequency
     *
     * @param string $frequency Digest frequency
     * @param string $today Today's date
     *
     * @return string Date string (YYYY-MM-DD)
     */
    protected function getSinceDate(string $frequency, string $today):string
    {
        switch ($frequency) {
            case 'daily':
                return date('Y-m-d', strtotime('-1 day', strtotime($today)));
            case 'weekly':
                return date('Y-m-d', strtotime('-1 week', strtotime($today)));
            case 'monthly':
                return date('Y-m-d', strtotime('-1 month', strtotime($today)));
            default:
                return '';
        }
    }
    /**
     * Send a digest email
     *
     * @param WP_User $user User object
     * @param string $frequency Digest frequency
     * @param array $notifications Regular notifications
     * @param array $content_updates Content update records
     *
     * @return bool Whether the email was sent
     */
    protected function sendDigestEmail(WP_User $user, string $frequency, array $notifications, array $content_updates):bool
    {
        // Generate subject based on frequency
        $subjects = [
            'daily'   => [
                "[edmonton.ink] Your " . date('l') . " Ink Update â™¡",
                "[edmonton.ink] Fresh Ink Alert - Your " . date('l') . " Digest",
                "[edmonton.ink] What You Missed in Edmonton's Tattoo Scene Today"
            ],
            'weekly'  => [
                "[edmonton.ink] Your Weekly Roundup from edmonton.ink â™¡",
                "[edmonton.ink] This Week in Edmonton's Tattoo Scene",
                "[edmonton.ink] Weekly Ink Update - Fresh From the Scene"
            ],
            'monthly' => [
                "[edmonton.ink] Monthly Ink Roundup â™¡",
                "[edmonton.ink] Your Monthly Scene Report: " . date('F') . " Edition",
                "[edmonton.ink] " . date('F') . " in Edmonton Tattoos"
            ]
        ];
        // Randomly select a subject for variety
        $subject_options = $subjects[ $frequency ] ?? [ "Your edmonton.ink Update" ];
        $subject         = $subject_options[ array_rand($subject_options) ];
        // Generate email content
        $content = $this->generateDigestContent($user, $frequency, $notifications, $content_updates);
        // Add tracking pixel
        $tracking_code = sprintf(
            '<img src="%s/track-email.php?uid=%s&digest=%s" width="1" height="1" alt="" />',
            site_url(),
            base64_encode($user->ID),
            $frequency
        );
        $content       .= $tracking_code;
        // Set header based on frequency
        $header = match ($frequency) {
            'daily' => "TODAY'S INK DROP",
            'weekly' => "THIS WEEK'S SCENE REPORT",
            'monthly' => "MONTHLY INK ROUNDUP",
            default => "YOUR INK UPDATES",
        };
        // Send the email
        return JVB()->email()->sendEmail($user->user_email, $subject, $content, $header);
    }
    /**
     * Generate HTML content for digest email
     *
     * @param WP_User $user User object
     * @param string $frequency Digest frequency
     * @param array $notifications Regular notifications
     * @param array $content_updates Content update records
     *
     * @return string HTML email content
     */
    protected function generateDigestContent(WP_User $user, string $frequency, array $notifications, array $content_updates):string
   /************************************************
    * PREFERENCES
   ************************************************/
    public function getUsersByFrequency(string $frequency):array
    {
        $content = sprintf('<p>Hey %s,</p>', $user->first_name ?: $user->display_name);
        // Intro text based on frequency
        switch ($frequency) {
            case 'daily':
                $content .= '<p>Here\'s what happened in Edmonton\'s tattoo scene today:</p>';
                break;
            case 'weekly':
                $content .= '<p>Here\'s what you missed in Edmonton\'s tattoo scene this week:</p>';
                break;
            case 'monthly':
                $content .= sprintf('<p>Here\'s your monthly roundup of what happened in %s in Edmonton\'s tattoo scene:</p>', date('F'));
                break;
        }
        // Process artist content updates - the most visually interesting part
        $content .= $this->generateContentUpdatesSection($content_updates);
        // Process regular notifications
        if (!empty($notifications)) {
            $content .= $this->generateNotificationsSection($notifications);
        }
        // Add footer content
        $content .= JVB()->email()->divider();
        $settings_url = add_query_arg([
            'utm_source'   => 'email',
            'utm_medium'   => 'digest',
            'utm_campaign' => $this->campaign
        ], site_url('/dash/settings/'));
        $content .= sprintf(
            '<p>You\'re receiving this %s digest because you follow artists on edmonton.ink. You can %s at any time.</p>',
            $frequency,
            '<a href="' . esc_url($settings_url) . '">adjust your notification settings</a>'
        );
        return $content;
        return $this->preferences->getUsersByFrequency($frequency);
    }
    /**
     * Generate HTML section for content updates
     *
     * @param array $content_updates Content update records
     *
     * @return string HTML content
     * TODO: This needs some work
     */
    protected function generateContentUpdatesSection(array $content_updates):string
    {
        if (empty($content_updates)) {
            return '';
        }
        $content = '';
        $cache   = Cache::for('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
        // Group updates by artist
        $updates_by_artist = [];
        foreach ($content_updates as $update) {
            if (!isset($updates_by_artist[$update->user_id])) {
                $updates_by_artist[ $update->user_id ] = [];
            }
            $updates_by_artist[ $update->user_id ][] = $update;
        }
        // Process each artist's updates
        foreach ($updates_by_artist as $artist_id => $updates) {
            $artist_data = $this->getArtistData($artist_id);
            if (!$artist_data) {
                continue;
            }
            // Combine all content updates from this artist
            $combined_new_items = [];
            foreach ($updates as $update) {
                $new_items = json_decode($update->new_items, true) ?: [];
                foreach ($new_items as $type => $ids) {
                    if (!isset($combined_new_items[ $type ])) {
                        $combined_new_items[ $type ] = [];
                    }
                    $combined_new_items[ $type ] = array_merge($combined_new_items[ $type ], $ids);
                    // Remove duplicates
                    $combined_new_items[ $type ] = array_unique($combined_new_items[ $type ]);
                }
            }
            // Skip if no content to show
            if (empty($combined_new_items)) {
                continue;
            }
            // Add artist header
            $content .= sprintf(
                '<h3><a href="%s" class="text-link">%s</a></h3>',
                esc_url(add_query_arg([
                    'utm_source'   => 'email',
                    'utm_medium'   => 'digest',
                    'utm_campaign' => $this->campaign
                ], $artist_data['url'])),
                esc_html($artist_data['display_name'])
            );
            // Process each content type
            foreach ($combined_new_items as $type => $ids) {
                if (empty($ids)) {
                    continue;
                }
                $clean_type = str_replace(BASE, '', $type);
                $type_label = $this->getContentTypeLabel($clean_type);
                // Add subheading for content type
                $content .= sprintf('<h4>%s</h4>', esc_html($type_label));
                // Display up to 3 items in a row
                $display_ids = array_slice($ids, 0, 3);
                $content     .= '<table><tr>';
                foreach ($display_ids as $item_id) {
                    // Get cached item data or fetch it
                    $cache_key = "digest_{$type}_{$item_id}";
                    $item_data = $cache->get($cache_key);
                    if ($item_data === false) {
                        $item_data = $this->getContentDetails($type, $item_id, $artist_id);
                        if ($item_data) {
                            $cache->set($cache_key, $item_data);
                        }
                    }
                    if (!$item_data) {
                        continue;
                    }
                    // Add item to email
                    $content .= '<td style="padding: 10px; width: 33.3%; vertical-align: top; text-align: center;">';
                    $content .= '<div style="margin-bottom: 10px;">';
                    $content .= sprintf(
                        '<a href="%s"><img src="%s" alt="%s" style="width: 100%%; max-width: 150px; height: auto; border-radius: 4px;"></a>',
                        esc_url(add_query_arg([
                            'utm_source'   => 'email',
                            'utm_medium'   => 'digest',
                            'utm_campaign' => $this->campaign
                        ], $item_data['url'])),
                        esc_url($item_data['image']),
                        esc_attr($item_data['title'])
                    );
                    $content .= '</div>';
                    $content .= sprintf('<p style="margin: 5px 0; font-weight: bold;">%s</p>', esc_html($item_data['title']));
                    $content .= '</td>';
                }
                // Fill empty cells if needed
                for ($i = count($display_ids); $i < 3; $i++) {
                    $content .= '<td style="width: 33.3%;"></td>';
                }
                $content .= '</tr></table>';
                // Add "See all" link if there are more items
                if (count($ids) > 3) {
                    $content .= sprintf(
                        '<p style="text-align: right;"><a href="%s" class="text-link">See all %d %s â†’</a></p>',
                        esc_url(add_query_arg([
                            'utm_source'   => 'email',
                            'utm_medium'   => 'digest',
                            'utm_campaign' => $this->campaign
                        ], site_url('/dash/feed/?type=' . $clean_type . '&artist=' . $artist_id))),
                        count($ids),
                        $this->pluralize($clean_type)
                    );
                }
            }
            $content .= '<div class="divider"></div>';
        }
        return $content;
    }
    /**
     * Generate HTML section for regular notifications
     *
     * @param array $notifications Notification objects
     *
     * @return string HTML content
     */
    protected function generateNotificationsSection(array $notifications):string
    public function getUserSubscriptions(int $userID, string $frequency):array
    {
        if (empty($notifications)) {
            return '';
        }
        $items = [];
        // Group notifications by type
        $by_type = [];
        foreach ($notifications as $notification) {
            if (!isset($by_type[$notification->type])) {
                $by_type[$notification->type] = [];
            }
            $by_type[$notification->type][] = $notification;
        }
        // Process each type
        foreach ($by_type as $type => $type_notifications) {
            foreach ($type_notifications as $notification) {
                $message = $notification->message;
                if (empty($message)) {
                    $message = $this->generateNotificationMessage($notification);
                }
                if (!empty($message)) {
                    $items[] = ['label' => '', 'value' => $message];
                }
            }
        }
        return JVB()->email()->table($items, 'Other Updates');
        return $this->preferences->getUserSubscriptions($userID, $frequency);
    }
    /**
     * Generate a message for a notification when none is provided
     *
     * @param object $notification Notification object
     *
     * @return string Formatted message
     */
    protected function generateNotificationMessage(object $notification):string
    {
        switch ($notification->type) {
            case 'new_favourite':
                return 'Someone favourited your content';
            case 'artist_approved':
                return 'Your artist profile has been approved';
            case 'artist_invitation':
                return 'You have a new invitation to join a shop';
            case 'new_term':
                return 'New term suggestion requires your approval';
            case 'term_approved':
                return 'Your term suggestion was approved';
            case 'term_rejected':
                return 'Your term suggestion was not approved';
            case 'list_shared':
                return 'Someone shared a list with you';
            default:
                return 'You have a new notification';
        }
    }
    /**
     * Get content details for digest
     *
     * @param string $type Content type (with jvb_ prefix)
     * @param int $content_id Content ID
     * @param int $artist_id Artist ID
     *
     * @return array|false Content details or false if not found
     */
    protected function getContentDetails(string $type, int $content_id, int $artist_id):array|false
    {
        // Build post type from content type
        $post_type = $type; // Already has jvb_ prefix
        // Get the post
        $post = get_post($content_id);
        if (!$post || $post->post_type !== $post_type || $post->post_status !== 'publish') {
            return false;
        }
        // Get artist data
        $artist_data = $this->getArtistData($artist_id);
        if (!$artist_data) {
            return false;
        }
        // Get featured image if available
        $image_url = get_the_post_thumbnail_url($content_id, 'medium');
        if (!$image_url) {
            // Try meta fields for image
            $meta_image_id = get_post_meta($content_id, BASE . 'image', true);
            if ($meta_image_id !== '') {
                $image_url = wp_get_attachment_image_url($meta_image_id, 'medium');
            }
        }
        if (!$image_url) {
            return false; // Skip items without images for digest
        }
        return [
            'id'          => $content_id,
            'title'       => $post->post_title,
            'url'         => get_permalink($content_id),
            'image'       => $image_url,
            'date'        => $post->post_date,
            'artist_id'   => $artist_id,
            'artist_name' => $artist_data['display_name'],
            'artist_url'  => $artist_data['url'],
            'type'        => str_replace(BASE, '', $type)
        ];
    }
    /**
     * Get human-readable label for content type
     *
     * @param string $type Content type (without jvb_ prefix)
     *
     * @return string Label
     */
    protected function getContentTypeLabel(string $type):string
    {
        $labels = [
            'tattoo'   => 'New Tattoos',
            'artwork'  => 'New Artwork',
            'piercing' => 'New Piercings',
            'event'    => 'Upcoming Events',
            'news'     => 'News & Updates',
            'offer'    => 'Special Offers'
        ];
        return $labels[$type] ?? ucfirst($type . 's');
    }
    /**
     * Update digest timestamps for notification preferences
     *
     * @param int $user_id User ID
     * @param string $frequency Digest frequency
     *
     * @return bool Success or failure
     */
    protected function updateDigestTimestamps(int $user_id, string $frequency):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->preferencesTable;
        $result = $wpdb->update(
            $table,
            [
                'last_sent' => current_time('mysql')
            ],
            [
                'user_id'   => $user_id,
                'frequency' => $frequency
            ]
        );
        return $result !== false;
    }
    /************************************************************
     * Bulk Operation Handlers
     ************************************************************/
    /**
     * Handle bulk operations for notifications
     *
     * @param WP_Error|array $result Default result
     * @param object $operation Operation object
     * @param array $data Current item
     *
     * @return WP_Error|array Operation result
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
    {
        switch ($operation->type) {
            case 'email_notification_digest':
                return $this->handleDigestBatch($operation, $data);
            case 'addNotification':
                return $this->handleAddNotificationOperation($operation, $data);
            default:
                return $result;
        }
    }
    /**
     * Handle digest batch operation
     *
     * @param object $operation Operation object
     * @param array $data
     *
     * @return WP_Error|array|bool Operation result
     */
    protected function handleDigestBatch(object $operation, array $data):WP_Error|array|bool
    {
        try {
            // Process one user at a time
            $user_id = $data['users'][ $operation->progress_count ] ?? null;
            if (!$user_id) {
                return [
                    'success' => false,
                    'message' => 'Invalid user ID'
                ];
            }
            $result = $this->generateUserDigest($user_id, $data['frequency']);
            return [
                'success' => $result,
                'result' => $result ? 'Digest sent successfully' : 'Failed to send digest',
            ];
        } catch (Exception $e) {
            $this->logError("Error processing digest batch: " . $e->getMessage(), [
                'operation_id' => $operation->id
            ]);
            return false;
        }
    }
    /**
     * @param object $operation
     * @param array $data
     *
     * @return WP_Error|array|bool
     */
    protected function handleAddNotificationOperation(object $operation, array $data):WP_Error|array|bool
    {
        try {
            $user_ids = $data['user_ids'] ?? [];
            $type = $data['type'] ?? '';
            $message = $data['message'] ?? '';
            $target_id = $data['target_id'] ?? null;
            $target_type = $data['target_type'] ?? null;
            if (empty($user_ids) || empty($type)) {
                return new WP_Error('invalid_data', 'Missing required notification data');
            }
            foreach ($user_ids as $key => $user_id) {
                if (!$this->checkUser($user_id)) {
                    unset($user_ids[$key]);
                }
            }
            // Add notification for this user
            $result = $this->processNotification($user_ids, $type, $message, $target_id, $target_type);
            return [
                'success' => !is_wp_error($result),
                'result' => is_wp_error($result) ? $result->get_error_message() : $result,
            ];
        } catch (Exception $e) {
            return [
                'success'   => false,
                'result'    =>  $e->getMessage()
            ];
        }
    }
    /**
     * @param int $user_id
     *
     * @return array|false|mixed
     */
    protected function getArtistData(int $user_id):array|false
    {
        // Try to get from cache
        $artist_id = get_user_meta($user_id, BASE . 'link', true);
        if (!$artist_id || $artist_id === '') {
            return false;
        }
        $cached = $this->artistsCache->get($artist_id);
        if ($cached !== false) {
            return $cached;
        }
        // Get basic artist data
        $artist_post = get_post($artist_id);
        if (!$artist_post) {
            return false;
        }
        $data = [
            'id' => $artist_id,
            'user_id' => $user_id,
            'display_name' => get_the_title($artist_id),
            'first_name' => get_post_meta($artist_id, BASE . 'first_name', true),
            'url' => get_permalink($artist_id),
            'image' => get_post_thumbnail_id($artist_id)
        ];
        // Cache the result
        $this->artistsCache->set($artist_id, $data);
        return $data;
    }
    /**
     * @param int $user_id
     *
     * @return array
     */
    protected function getFollowedArtists(int $user_id):array
    {
        return $this->favouritesCache->remember(
            $user_id,
            function() use ($user_id) {
                global $wpdb;
                $favourites_table = $wpdb->prefix . BASE . 'favourites';
                // Get artists this user has favourited
                return $wpdb->get_col($wpdb->prepare(
                "SELECT f.target_id
                     FROM {$favourites_table} f
                     JOIN {$wpdb->posts} p ON f.target_id = p.ID
                     WHERE f.user_id = %d
                     AND f.type = 'artist'
                     AND p.post_status = 'publish'",
                    $user_id
                ));
            }
        );
    }
    /**
     * @param int $artist_id
     *
     * @return int
     */
    protected function getFollowerCount(int $artist_id):int
    {
        return $this->followerCache->remember(
            $artist_id,
            function() use ($artist_id) {
                global $wpdb;
                $favourites_table = $wpdb->prefix . BASE . 'favourites';
                return $wpdb->get_var($wpdb->prepare(
                    "SELECT COUNT(DISTINCT user_id)
                     FROM {$favourites_table}
                     WHERE target_id = %d AND type = 'artist'",
                    $artist_id
                ));
            }
        );
    }
    /**
     * @param string $word
     *
     * @return string
     */
    protected function pluralize(string $content):string
    {
        $registrar = Registrar::getInstance($content);
        return ($registrar) ? $registrar->getPlural()
            : str_replace('_', ' ', $content.'s');
    }
    /**
     * @param int $user_id
     *
     * @return void
     */
    protected function clearNotificationCache(int $user_id):void
    {
        $this->userCache->forget($user_id);
        $this->contentCache->forget($user_id);
    }
    /**
     * Log errors with proper context using the error handler
     *
     * @param string $message Error message
     * @param array $context Additional context data
     * @param string $severity Error severity (debug, info, warning, error, critical)
     * @return void
     */
    protected function logError(string $message, array $context = [], string $severity = 'error'):void
    {
        try {
            // Use the ErrorHandler through the JVB singleton
            JVB()->error()->log(
                'notifications', // component
                $message,
                $context,
                $severity
            );
        } catch (Exception $e) {
            // Fallback if error handler fails
            error_log("NotificationManager Error: $message - " . json_encode($context));
        }
    }
    /**
     * @return void
     */
    public function cleanupOldNotifications():void
    {
        global $wpdb;
        $notifications_table = $wpdb->prefix . $this->table;
        $seen_table = $wpdb->prefix . $this->seenTable;
        // Keep track of current time
        $current_time = current_time('mysql');
        // Delete regular notifications older than 3 months
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$notifications_table}
         WHERE created_at < DATE_SUB(%s, INTERVAL 3 MONTH)
         AND status IN ('read', 'actioned', 'dismissed')",
            $current_time
        ));
        // Delete dismissed content notifications older than 1 month
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$seen_table}
         WHERE status = 'dismissed'
         AND created_at < DATE_SUB(%s, INTERVAL 1 MONTH)",
            $current_time
        ));
        // Delete read content notifications older than 3 months
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$seen_table}
         WHERE status = 'read'
         AND created_at < DATE_SUB(%s, INTERVAL 3 MONTH)",
            $current_time
        ));
    }
    /**
     * @return array
     */
    protected function getVerified(string|array $userRoles):array
    {
        $userRoles = $this->checkRoles($userRoles);
        if (empty($userRoles)) {
            return [];
        }
        $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user',true);
        return $cache->remember(
            'verified',
            function() use ($userRoles) {
                return get_users([
                    'role'          => $userRoles,
                    'capability'    => 'skip_moderation',
                    'fields'        => 'ID'
                ]);
            }
        );
    }
    protected function getUserIDs(array|string $roles):array
    public function addUserPreference(int $userID, int $item_id, string $item_type, string $frequency):bool
    {
        $roles = $this->checkRoles($roles);
        if (empty($roles)) {
            return [];
        }
        $cache = Cache::for('everyone', DAY_IN_SECONDS)->connect('user', true);
        return $cache->remember(
            $cache->generateKey($roles),
            function() use ($roles) {
                return get_users([
                    'role'  => $roles,
                    'fields'    => 'ID'
                ]);
            }
        );
        return $this->preferences->addUserPreference($userID, $item_id, $item_type, $frequency);
    }
    protected function checkRoles(string|array $roles):array
    public function deleteUserPreference(int $userID, int $item_id, string $item_type):bool
    {
        if (!is_array($roles)) {
            $roles = explode(',',$roles);
        }
        return array_map(function ($r) {
            return jvbCheckBase(trim($r));
        }, array_filter($roles, function ($r) {
            return Registrar::getInstance(trim($r))!== false;
        }));
        return $this->preferences->deleteUserPreference($userID, $item_id, $item_type);
    }
   /************************************************
    * NOTIFICATIONS
   ************************************************/
    public function notify(int|array $user_ids, string $notification_type, int $fromUser = 0, array $args = []):bool
    {
        return $this->notifications->notify($user_ids, $notification_type, $fromUser, $args);
    }
    public function unnotify(int|array $user_ids, string $notification_type, int $fromUser = 0, array $args = []):bool
    {
        return $this->notifications->unnotify($user_ids, $notification_type, $fromUser, $args);
    }
   public function getUserNotifications(int $user_id, array $args = []):array
   {
       return $this->notifications->getUserNotifications($user_id, $args);
   }
    /**
     * @param int $userID
     *
     * @return bool|mixed
     */
    protected function checkUser(int $userID):bool
    {
        $cache = Cache::for('checked_users', DAY_IN_SECONDS)->connect('user', true);
        return $cache->remember(
            $userID,
            function() use ($userID) {
                return (bool)get_userdata($userID)?:null;
            }
        );
    }
   public function markRead(int $userID, int|array $notification_id):bool
   {
       return $this->notifications->markRead($userID, $notification_id);
   }
   public function markDismissed(int $userID, int|array $notification_id):bool
   {
       return $this->notifications->markDismissed($userID, $notification_id);
   }
   public function markActioned(int $userID, int|array $notification_id, array $result = []):bool
   {
       return $this->notifications->markActioned($userID, $notification_id, $result);
   }
    /************************************************
     * CONTENT NOTIFICATIONS
     * These are pooled notifications of new content for:
     *      - a particular artist
     *      - a particular term
     ************************************************/
   /************************************************
    * EMAIL DIGESTS
    ************************************************/
}
inc/managers/Notifications/Content.php
New file
@@ -0,0 +1,108 @@
<?php
namespace JVBase\managers\Notifications;
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use WP_Query;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * ContentNotifications
 *  Gets notifications of
 **/
class Content
{
    protected static Cache $cache;
    public function __construct()
    {
        self::$cache = Cache::for('content_notifications')->connect('post');
    }
    public static function getSinceDate(string $date):string
    {
        return match ($date) {
            'week' => '-1 week',
            'month' => '-1 month',
            default => '-1 day',
        };
    }
    public static function forUser(int $userID, ?string $sinceDate = null, bool $modified = false):array
    {
        $date = self::getSinceDate($sinceDate);
        $posts = new WP_Query([
            'posts_per_page'    => -1,
            'post_status'       => 'publish',
            'post_author'       => $userID,
            'date_query'        => [
                [
                    'after'     => $date,
                    'inclusive' => true,
                    'column'    => $modified ? 'post_modified' : 'post_date'
                ]
            ],
            'fields' => 'ids',
        ]);
        wp_reset_postdata();
        return $posts->posts;
    }
    public static function forTerm(int $termID, string $taxonomy, ?string $sinceDate = null, bool $modified = false):array
    {
        if (!in_array($taxonomy, ['category', 'tag'])){
            $taxonomy = jvbCheckBase($taxonomy);
        }
        $date = self::getSinceDate($sinceDate);
        $posts = new WP_Query([
            'posts_per_page'    => -1,
            'post_status'       => 'publish',
            'date_query'        => [
                [
                    'after'     => $date,
                    'inclusive' => true,
                    'column'    => $modified ? 'post_modified' : 'post_date'
                ]
            ],
            'tax_query'         => [
                [
                    'taxonomy'  => $taxonomy,
                    'terms'     => $termID,
                ]
            ],
            'fields'    => 'ids',
        ]);
        wp_reset_postdata();
        return $posts->posts;
    }
    public static function formatForNotification(array $IDs, string $type):array
    {
        $posts = [];
        foreach ($IDs as $ID) {
            $posts[] = self::$cache->remember(
                $ID,
                function() use ($ID) {
                    $meta = Meta::forPost($ID);
                    $img = $meta->get('post_thumbnail');
                    $registrar = Registrar::getInstance(get_post_type($ID));
                    return array_merge([
                        'url'       => get_the_permalink($ID),
                        'content'   => $registrar->getType(),
                        'icon'      => $registrar->getIcon(),
                        'image'     => (!empty($img) ? jvbImageData($img) : [])
                    ],
                        $meta->getAll([
                            'post_date',
                            'post_modified',
                            'post_title',
                            'post_excerpt',
                        ])
                    );
                }
            );
        }
        return $posts;
    }
}
inc/managers/Notifications/EmailDigests.php
New file
@@ -0,0 +1,391 @@
<?php
namespace JVBase\managers\Notifications;
use JVBase\managers\Cache;
use JVBase\managers\CustomTable;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use WP_Error;
use WP_Query;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * EmailDigests
 *  Prepares the email digest for daily/weekly/monthly email summaries
 **/
class EmailDigests
{
    protected string $campaign;
    protected Cache $terms;
    protected Cache $users;
    protected CustomTable $userIndex;
    protected CustomTable $termIndex;
    public function __construct()
    {
        $this->registerCron();
        $this->registerTable();
        add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
    }
    protected function registerCron():void
    {
        add_action(BASE . 'notification_digest_daily', [ $this, 'runDailyDigests' ]);
        add_action(BASE . 'notification_digest_weekly', [ $this, 'runWeeklyDigests' ]);
        add_action(BASE . 'notification_digest_monthly', [ $this, 'runMonthlyDigests' ]);
    }
    protected function registerTable():void
    {
        $this->registerUserIndex();
        $this->registerTermIndex();
    }
        protected function registerUserIndex():void
        {
            $table = CustomTable::for('user_notification_email_digest');
//      $types = implode(',',array_map(function($item) { return "'{$item}'"; }, Registrar::getFeatured('favouritable')));
            $table->setColumns([
                'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'user_id'       => "{$table->getUserIDType()} NOT NULL",
                'frequency'     => "ENUM('daily', 'weekly', 'monthly') NOT NULL",
                'content_ids'   => 'JSON DEFAULT NULL',
                'output'        => 'TEXT DEFAULT NULL',
                'total_sent'    => 'int unsigned DEFAULT 0',
                'created_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key' => 'UNIQUE', 'value' => '`user_frequency` (`user_id`, `frequency`)'],
                'user_id (`user_id`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}digest_user` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->userIndex = $table;
        }
        protected function registerTermIndex():void
        {
            $table = CustomTable::for('user_notification_email_digest');
            $types = implode(',',array_map(function($item) { return "'{$item}'"; }, Registrar::getFeatured('favouritable', 'term')));
            $table->setColumns([
                'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'term_id'       => "{$table->getTermIDType()} NOT NULL",
                'type'          => "ENUM({$types}) NOT NULL",
                'frequency'     => "ENUM('daily', 'weekly', 'monthly') NOT NULL",
                'content_ids'   => 'JSON DEFAULT NULL',
                'output'        => 'TEXT DEFAULT NULL',
                'total_sent'    => 'int unsigned DEFAULT 0',
                'created_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key' => 'UNIQUE', 'value' => '`term_frequency` (`term_id`, `type`, `frequency`)'],
                'term_id (`term_id`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}digest_term` FOREIGN KEY (`term_id`)
                REFERENCES `{$table->getTermTable()}` (`term_id`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->termIndex = $table;
        }
    public function runDailyDigests():void
    {
        $this->campaign = 'daily_digest_' .date('Y-m-d');
        $this->processDigest('daily');
    }
    public function runWeeklyDigests():void
    {
        $this->campaign = 'weekly_digest_'.date('Y-m-d');
        $this->processDigest('weekly');
    }
    public function runMonthlyDigests():void
    {
        $this->campaign = 'monthly_digest_'.date('Y-m-d');
        $this->processDigest('monthly');
    }
    protected function processDigest(string $frequency):void
    {
        $users = $this->getUsers($frequency);
        if (empty($users)){
            return;
        }
        JVB()->queue()->add(
            'email_notification_digest',
            0,
            [
                'frequency' => $frequency,
                'users'     => $users,
            ],
            [
                'chunk_key' => 'users',
                'chunk_size'=> 20,
                'operation_id'=> 'notification_digest_'.date('Y_m_d_His')
            ]
        );
    }
    public function getUsers(string $frequency):array
    {
        if (!in_array($frequency, ['never', 'daily', 'weekly', 'monthly'])) {
            return [];
        }
        return JVB()->notification()->getUsersByFrequency($frequency);
    }
    public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
    {
        if ($operation->type !== 'email_notification_digest') {
            return $result;
        }
        $results = [];
        foreach ($data['user'] as $userID) {
            $result = $this->generateUserDigest($userID, $data['frequency']);
            if ($result) {
                $results[$userID] = $result;
            }
            usleep(50);
        }
        return [
            'success'   => true,
            'result'    => $results
        ];
    }
    protected function getSinceDate(string $frequency):string
    {
        return match ($frequency) {
            'weekly' => '-1 week',
            'monthly' => '-1 month',
            default => '-1 day',
        };
    }
    protected function generateUserDigest(int $userID, string $frequency):string|false
    {
        $subscription = JVB()->notification()->getUserSubscriptions($userID, $frequency);
        //TODO
        $notifications = JVB()->notification()->getUserNotifications($userID);
        if (empty($subscription) && empty($notifications)) {
            return false;
        }
        $content = '';
        foreach ($subscription as $item) {
            $temp = match ($item['item_type']) {
                array_merge(['user'], Registrar::getFeatured('favouritable', 'user')) => $this->getUserUpdates($item['item_id'], $frequency),
                Registrar::getFeatured('favouritable', 'term') => $this->getTermUpdates($item['item_id'], $item['item_type'], $frequency),
                default => false,
            };
            if ($temp) {
                $content .= $temp;
            }
        }
        if (empty($content)) {
            return false;
        }
        return $content;
    }
    protected function getUserUpdates(int $userID, string $frequency):string|false
    {
        $frequency = strtolower($frequency);
        $frequency = in_array($frequency, ['daily', 'weekly', 'monthly']) ? $frequency : 'daily';
        $user = get_userdata($userID);
        if (!$user || is_wp_error($user)) {
            return false;
        }
        $since = $this->getSinceDate($frequency);
        $entry = $this->userIndex->get([
            'user_id'   => $userID,
            'frequency' => $frequency
        ]);
        if ($entry) {
            return $entry->output;
        }
        $new = [
            'user_id'   => $userID,
            'frequency' => $frequency,
        ];
        //Didn't do it yet, create it here
        $IDs = Content::forUser($userID, $since);
        $new['content_ids'] = json_encode($IDs);
        $new['output'] = $this->contentForUser($userID, $IDs);
        $this->userIndex->insert($new);
        return $new['output'];
    }
    protected function contentForUser(int $userID, array $IDs):string
    {
        if (empty($IDs)) {
            return '';
        }
        $user = get_userdata($userID);
        if (!$user || is_wp_error($user)) {
            return '';
        }
        $role = jvbUserRole($userID);
        $registrar = Registrar::getInstance($role);
        if (!$registrar) {
            return '';
        }
        if ($registrar->profile_link) {
            $meta = Meta::forPost(jvbUserProfileLink($userID));
            $title = $meta->get('post_title');
            $goTo = get_the_permalink(jvbUserProfileLink($userID));
        }else {
            $meta = Meta::forUser($userID);
            $title = $meta->get('display_name');
            $goTo = get_author_posts_url($userID);
        }
        $output = [];
        $count = count($IDs);
        $max = (min($count, 5));
        $canIncrease = $count > $max;
        for ($i = 0; $i <=$max; $i++) {
            $ID = $IDs[$i];
            $m = Meta::forPost($ID);
            $img = $m->get('post_thumbnail');
            if (empty($img)) {
                if ($canIncrease){
                    $max++;
                }
                continue;
            }
            $image =  wp_get_attachment_image_src($img, 'medium');
            if (!$image) {
                if ($canIncrease){
                    $max++;
                }
                continue;
            }
            $url = get_the_permalink($ID);
            $image = sprintf(
                '<a href="%s">%s</a>',
                $url,
                JVB()->email()->image($image[0], get_post_meta($img, '_wp_attachment_image_alt', true))
            );
            $t = sprintf(
                '<a href="%s">%s</a>',
                $url,
                $m->get('post_title')
            );
            $output[] = JVB()->email()->card($image, $t);
        }
        $final = JVB()->email()->button($goTo, 'Want to See More?');
        return JVB()->email()->grid($output, 3, 'New from '.$title,'',$final);
    }
    protected function getTermUpdates(int $termID, string $type, string $frequency):string|false
    {
        $frequency = strtolower($frequency);
        $frequency = in_array($frequency, ['daily', 'weekly', 'monthly']) ? $frequency : 'daily';
        $taxonomy = jvbCheckBase($type);
        $term = get_term($termID, $taxonomy);
        if (!$term || is_wp_error($term)) {
            return false;
        }
        $since = $this->getSinceDate($frequency);
        $entry = $this->termIndex->get([
            'term_id'   => $termID,
            'type'      => $type,
            'frequency' => $frequency
        ]);
        if ($entry) {
            return $entry->output;
        }
        $new = [
            'term_id'   => $termID,
            'type'      => $type,
            'frequency' => $frequency
        ];
        //Didn't do it yet, create it here
        $IDs = Content::forTerm($termID, $taxonomy, $since);
        $new['content_ids'] = json_encode($IDs);
        $new['output'] = $this->contentForTerm($termID, $type, $IDs);
        $this->termIndex->insert($new);
        return $new['output'];
    }
    protected function contentForTerm(int $termID, string $tax, array $IDs):string
    {
        if (empty($IDs)) {
            return '';
        }
        $taxonomy = jvbCheckBase($tax);
        $term = get_term($termID, $taxonomy);
        if (!$term || is_wp_error($term)) {
            return '';
        }
        $registrar = Registrar::getInstance($tax);
        if (!$registrar) {
            return '';
        }
        $meta = Meta::forTerm($termID);
        $title = $meta->get('name');
        $goTo = get_term_link($termID,$taxonomy);
        $output = [];
        $count = count($IDs);
        $max = (min($count, 5));
        $canIncrease = $count > $max;
        for ($i = 0; $i <=$max; $i++) {
            $ID = $IDs[$i];
            $m = Meta::forPost($ID);
            $img = $m->get('post_thumbnail');
            if (empty($img)) {
                if ($canIncrease){
                    $max++;
                }
                continue;
            }
            $image =  wp_get_attachment_image_src($img, 'medium');
            if (!$image) {
                if ($canIncrease){
                    $max++;
                }
                continue;
            }
            $url = get_the_permalink($ID);
            $image = sprintf(
                '<a href="%s">%s</a>',
                $url,
                JVB()->email()->image($image[0], get_post_meta($img, '_wp_attachment_image_alt', true))
            );
            $t = sprintf(
                '<a href="%s">%s</a>',
                $url,
                $m->get('post_title')
            );
            $output[] = JVB()->email()->card($image, $t);
        }
        $final = JVB()->email()->button($goTo, 'See More');
        return JVB()->email()->grid($output, 3, 'New in '.$title, '', $final);
    }
}
inc/managers/Notifications/Notifications.php
New file
@@ -0,0 +1,470 @@
<?php
namespace JVBase\managers\Notifications;
use Exception;
use JVBase\managers\CustomTable;
use JVBase\registrar\Registrar;
use JVBase\utility\Features;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Preferences
 *  Manages preferences for a user's favourites
 **/
class Notifications
{
    protected array $types;
    protected CustomTable $notifications;
    public function __construct()
    {
        $this->defineTypes();
        $this->defineTable();
    }
    private function defineTypes():void
    {
        $types = ['system_message' => [
            'icon'  => 'info',
            'digest'=> true
        ]];
        if (Features::forSite()->has('favourites')) {
            $types = array_merge($types, [
                'new_favourite' => [
                    'icon'  => 'heart',
                    'digest'=> true
                ],
                'list_shared'  => [
                    'icon'  => 'list-heart',
                    'digest'=> false
                ],
                'list_share_status'  => [
                    'icon'  => 'list-heart',
                    'digest'=> false
                ]
            ]);
        }
        $contentTax = Registrar::getFeatured('is_content', 'term');
        $verifyEntry = Registrar::getFeatured('verify_entry', 'term');
        if (!empty(array_intersect($contentTax, $verifyEntry))) {
            $types = array_merge($types, [
                'entry_requested'  => [
                    'icon'  => 'user-circle-plus',
                    'digest'=> true,
                    'action'=> ['approve', 'reject', 'dismiss']
                ],
                'entry_approved' => [
                    'icon'  => 'user-circle-check',
                    'digest'=> false
                ],
                'entry_denied' => [
                    'icon'  => 'prohibit',
                    'digest'=> false
                ],
                'entry_revoked' => [
                    'icon'  => 'warning-circle',
                    'digest'=> false
                ]
            ]);
        }
        $invitable = Registrar::getFeatured('invitable');
        if (!empty($invitable)) {
            $types = array_merge($types, [
                'invitation_requested' => [
                    'icon'  => 'plus-circle',
                    'digest'=> true,
                    'note'  => true,
                    'action'=> ['approve','reject','dismiss']
                ],
                'invitation_granted' => [
                    'icon'  => 'check-circle',
                    'digest'=> false
                ],
                'invitation_revoked' => [
                    'icon'  => 'x-circle',
                    'digest'=> false
                ],
                'invitation_accepted' => [
                    'icon'  => 'check-circle',
                    'digest'=> false
                ],
                'invitation_refused'  => [
                    'icon'  => 'x-circle',
                    'digest'=> false
                ]
            ]);
        }
        $approvals = Registrar::getFeatured('approve_new');
        if (!empty($approvals)) {
            $tmp = ['user', 'term', 'post'];
            $app = [];
            foreach ($tmp as $t) {
                $approvals = Registrar::getFeatured('approve_new', $t);
                if (!empty($approvals)) {
                    $app = array_merge($app, [
                        $t.'_new' => [
                            'icon'  => 'plus-circle',
                            'digest'=> true
                        ],          //For newly created items
                        $t.'_approved' => [
                            'icon'  => 'check-circle',
                            'digest'=> false
                        ],      //For notices of acceptance
                        $t.'_denied' => [
                            'icon'  => 'x-circle',
                            'digest'=> false
                        ],      //For notices of denial
                        $t.'_status' => [
                            'icon'  => 'info',
                            'digest'=> true
                        ],      //For final status
                    ]);
                }
            }
            $types = array_merge($types, $app);
        }
        $this->types = $types;
    }
    private function defineTable():void
    {
        $table = CustomTable::for('notifications_main');
        $typeEnum = implode(',',array_map(function($type) { return '`'.$type.'`';}, array_keys($this->types)));
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'for_user'      => $table->getUserIDType().' NOT NULL',
            'from_user'     => $table->getUserIDType().' NOT NULL',
            'target_id'     => 'bigint(20) DEFAULT NULL',
            'target_type'   => 'varchar(30) DEFAULT NULL',
            'type'          => "ENUM({$typeEnum}) NOT NULL DEFAULT 'system_message'",
            'status'        => "ENUM('unread', 'read', 'actioned', 'dismissed') NOT NULL DEFAULT 'unread'",
            'priority'      => "ENUM('low','normal','high') NOT NULL DEFAULT 'normal'",
            'message'       => 'varchar(255) DEFAULT NULL',
            'action_taken'  => 'tinyint(1) NOT NULL DEFAULT 0',
            'result'        => 'JSON DEFAULT NULL',
            'created_at'    => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
            'read_at'       => 'datetime DEFAULT NULL',
            'actioned_at'   => 'datetime DEFAULT NULL',
            'updated_at'    => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            '`user_status` (`for_user`, `status`)',
            '`target_lookup` (`target_id`, `target_type`)',
            '`unread_notifications` (`for_user`, `status`, `created_at`)',
            '`requires_action` (`for_user`, `requires_action`, `action_taken`)',
            '`from_user_lookup` (`for_user`, `from_user`, `target_id`, `target_type`, `type`)'
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}notify_owner` FOREIGN KEY (`for_user`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}from_user` FOREIGN KEY (`from_user`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
        ]);
        $table->defineTable();
        $this->notifications = $table;
    }
    protected function checkArgs(array $args):array
    {
        if (empty($args)) {
            return $args;
        }
        $allowedKeys = ['target_id', 'target_type','priority','message'];
        $allowed = array_filter($args, function($key) use ($allowedKeys) {
            return !in_array($key, $allowedKeys);
        }, ARRAY_FILTER_USE_KEY);
        $notAllowed = array_diff($allowedKeys, $allowed);
        if (!empty($notAllowed)) {
            error_log('[Notifications]::checkArgs Attempted keys not allowed:'.print_r($notAllowed, true));
        }
        $out = [];
        foreach ($allowed as $key => $value) {
            switch ($key) {
                case 'target_id':
                    $value = absint($value);
                    if ($value > 0) {
                        $out[$key] = $value;
                    }
                    break;
                case 'target_type':
                    $registrar = Registrar::getInstance($value);
                    if ($registrar) {
                        $out[$key] = $value;
                    }
                    break;
                case 'priority':
                    $value = strtolower($value);
                    $value = in_array($value, ['low','normal','high']) ? $value : false;
                    if ($value){
                        $out[$key] = $value;
                    }
                    break;
                case 'message':
                    $value = sanitize_text_field($value);
                    $out[$key] = $value;
                    break;
            }
        }
        return $out;
    }
    public function notify(int|array $user_ids, string $n_type, int $fromUser = 0, array $args = []):bool
    {
        $args = $this->checkArgs($args);
        $n_type = strtolower($n_type);
        if (!in_array($n_type, array_keys($this->types))) {
            error_log('[Notifications]::notify Invalid notification type: '.$n_type);
            return false;
        }
        if (is_array($user_ids)) {
            return $this->notifications->transaction(
                function() use ($user_ids, $n_type, $fromUser, $args) {
                    $results = [];
                    foreach ($user_ids as $user_id) {
                        $results[$user_id] = $this->notifications->findOrCreate([
                            'for_user'  => $user_id,
                            'from_user' => $fromUser,
                            'target_id' => $args['target_id']??null,
                            'target_type'=> $args['target_type']??null,
                            'type'      => $n_type
                        ], [
                            'message' => $args['message']??null,
                        ]);
                    }
                    return true;
                }
            );
        }
        return $this->notifications->findOrCreate([
            'for_user'  => $user_ids,
            'from_user' => $fromUser,
            'target_id' => $args['target_id']??null,
            'target_type'=> $args['target_type']??null,
            'type'      => $n_type
        ], [
            'message' => $args['message']??null,
        ]);
    }
    public function unnotify(int|array $user_ids, string $n_type, int $fromUser = 0, array $args = []):bool
    {
        $args = $this->checkArgs($args);
        $n_type = strtolower($n_type);
        if (!in_array($n_type, array_keys($this->types))) {
            error_log('[Notifications]::notify Invalid notification type: '.$n_type);
            return false;
        }
        if (is_array($user_ids)) {
            return $this->notifications->transaction(
                function() use ($user_ids, $n_type, $fromUser, $args) {
                    foreach ($user_ids as $user_id) {
                        $item = $this->notifications->get([
                            'for_user'  => $user_id,
                            'from_user' => $fromUser,
                            'target_id' => $args['target_id']??null,
                            'target_type'=> $args['target_type']??null,
                            'type'      => $n_type
                        ]);
                        if ($item) {
                            $this->notifications->delete([
                                'for_user'  => $user_id,
                                'from_user' => $fromUser,
                                'target_id' => $args['target_id']??null,
                                'target_type'=> $args['target_type']??null,
                                'type'      => $n_type
                            ]);
                        }
                    }
                    return true;
                }
            );
        }
        $item = $this->notifications->get([
            'for_user'  => $user_ids,
            'from_user' => $fromUser,
            'target_id' => $args['target_id']??null,
            'target_type'=> $args['target_type']??null,
            'type'      => $n_type
        ]);
        if ($item) {
            return $this->notifications->delete([
                'for_user'  => $user_ids,
                'from_user' => $fromUser,
                'target_id' => $args['target_id']??null,
                'target_type'=> $args['target_type']??null,
                'type'      => $n_type
            ]);
        }
        return true;
    }
    public function getUserNotifications(int $user_id, array $where = []):array
    {
        $gotWhere = $where;
        $allowedKeys = ['from_user','target_id', 'target_type','type','status','priority','action_taken','created_at','read_at','actioned_at','updated_at'];
        $where = array_filter($gotWhere, function($key) use ($allowedKeys) {
            return in_array($key, $allowedKeys);
        }, ARRAY_FILTER_USE_KEY);
        $notAllowed = array_diff($gotWhere, $allowedKeys);
        if (!empty($notAllowed)){
            error_log('[Notifications]::getUserNotifications Invalid where arguments, removed from request: '.print_r($notAllowed, true));
        }
        return $this->notifications->getMany(array_merge([
            'for_user'  => $user_id,
            'status'    => 'unread',    //default to unread, but the merge will override if different
        ], $where));
    }
    public function markRead(int $userID, int|array $notification_id):bool
    {
        if (is_int($notification_id)) {
            $notification = $this->notifications->get(['id' => $notification_id]);
            if (!$notification) {
                return false;
            }
            if ($notification['for_user'] !== $userID) {
                return false;
            }
            return (bool) $this->notifications->update([
                'status'    => 'read'
            ], [
                'id'    => $notification_id
            ]);
        }
        try {
            $this->notifications->transaction(
                function () use ($notification_id, $userID) {
                    foreach ($notification_id as $id) {
                        $notification = $this->notifications->get(['id' => $notification_id]);
                        if (!$notification) {
                            continue;
                        }
                        if ($notification['for_user'] !== $userID) {
                            continue;
                        }
                        $this->notifications->update([
                            'status'    => 'read'
                        ], [
                            'id'    => $id
                        ]);
                    }
                }
            );
            return true;
        } catch (Exception $e) {
            return false;
        }
    }
    public function markDismissed(int $userID, int|array $notification_id):bool
    {
        if (is_int($notification_id)) {
            $notification = $this->notifications->get(['id' => $notification_id]);
            if (!$notification) {
                return false;
            }
            if ($notification['for_user'] !== $userID) {
                return false;
            }
            return (bool) $this->notifications->update([
                'status'    => 'dismissed'
            ], [
                'id'    => $notification_id
            ]);
        }
        try {
            $this->notifications->transaction(
                function () use ($notification_id, $userID) {
                    foreach ($notification_id as $id) {
                        $notification = $this->notifications->get(['id' => $notification_id]);
                        if (!$notification) {
                            continue;
                        }
                        if ($notification['for_user'] !== $userID) {
                            continue;
                        }
                        $this->notifications->update([
                            'status'    => 'dismissed'
                        ], [
                            'id'    => $id
                        ]);
                    }
                }
            );
            return true;
        } catch (Exception $e) {
            return false;
        }
    }
    public function markActioned(int $userID, int|array $notification_id, array $result = []):bool
    {
        $update = [
            'status'    => 'actioned',
            'action_taken'=> 1,
        ];
        if (!empty($result)) {
            $update['result'] = json_encode($result);
        }
        if (is_int($notification_id)) {
            $notification = $this->notifications->get(['id' => $notification_id]);
            if (!$notification) {
                return false;
            }
            if ($notification['for_user'] !== $userID) {
                return false;
            }
            return (bool) $this->notifications->update(
                $update,
                [
                    'id'    => $notification_id
                ]
            );
        }
        try {
            $this->notifications->transaction(
                function () use ($notification_id, $userID, $update) {
                    foreach ($notification_id as $id) {
                        $notification = $this->notifications->get(['id' => $notification_id]);
                        if (!$notification) {
                            continue;
                        }
                        if ($notification['for_user'] !== $userID) {
                            continue;
                        }
                        $this->notifications->update(
                            $update,
                            [
                                'id'    => $notification_id
                            ]
                        );
                    }
                }
            );
            return true;
        } catch (Exception $e) {
            return false;
        }
    }
}
inc/managers/Notifications/Preferences.php
New file
@@ -0,0 +1,124 @@
<?php
namespace JVBase\managers\Notifications;
use JVBase\managers\CustomTable;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Preferences
 *  Manages preferences for a user's favourites
 **/
class Preferences
{
    protected CustomTable $preferences;
    public function __construct()
    {
        $this->defineTable();
    }
    private function defineTable():void
    {
        $table = CustomTable::for('notifications_preferences');
        $table->setColumns([
            'id'                => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'user_id'           => "{$table->getUserIDType()} NOT NULL",
            'item_id'           => 'bigint(20) NOT NULL',
            'item_type'         => 'varchar(50) NOT NULL',
            'frequency'         => "ENUM('never', 'daily', 'weekly', 'monthly') DEFAULT 'never'",
            'last_sent'         => 'datetime DEFAULT NULL',
            'created_at'        => 'datetime DEFAULT CURRENT_TIMESTAMP',
            'updated_at'        => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => '`user_type` (`user_id`, `item_id`)'],
            '`user_frequency` (`user_id`, `frequency`)',
            '`frequency_lookup` (`frequency`, `last_sent`)'
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}notification_pref_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
        ]);
        $table->defineTable();
        $this->preferences = $table;
    }
    public function getUsersByFrequency(string $frequency):array
    {
        if (!in_array($frequency, ['never', 'daily', 'weekly', 'monthly'])) {
            return [];
        }
        return array_unique($this->preferences->pluck('user_id', ['frequency' => $frequency]));
    }
    public function getUserSubscriptions(int $userID, string $frequency):array
    {
        if (!in_array($frequency, ['never', 'daily', 'weekly', 'monthly'])) {
            return [];
        }
        return $this->preferences->getMany([
            'user_id'   => $userID,
            'frequency' => $frequency
        ]);
    }
    public function addUserPreference(int $userID, int $item_id, string $item_type, string $frequency):bool
    {
        if (!in_array($frequency, ['never', 'daily', 'weekly', 'monthly'])) {
            return false;
        }
        $user = get_userdata($userID);
        if (!$user || is_wp_error($user)) {
            return false;
        }
        $registrar = Registrar::getInstance($item_type);
        if (!$registrar) {
            return false;
        }
        $type = $registrar->getType();
        switch ($type) {
            case 'term':
                $term = get_term($item_id, $registrar->getBased());
                if (!$term || is_wp_error($term)) {
                    return false;
                }
                break;
            case 'user':
                $user = get_userdata($item_id);
                if (!$user || is_wp_error($user)) {
                    return false;
                }
                break;
        }
        return $this->preferences->findOrCreate(
            [
                'user_id'   => $userID,
                'item_id'   => $item_id,
                'item_type' => $item_type
            ],
            [
                'frequency' => $frequency
            ]
        );
    }
    public function deleteUserPreference(int $userID, int $item_id, string $item_type):bool
    {
        $hasRecord = $this->preferences->get(['user_id' => $userID, 'item_id' => $item_id, 'item_type' => $item_type]);
        if (!$hasRecord) {
            return false;
        }
        return $this->preferences->delete([
            'user_id'   => $userID,
            'item_id'   => $item_id,
            'item_type' => $item_type
        ]);
    }
}
inc/managers/Notifications/_setup.php
inc/managers/ReferralManager.php
@@ -31,6 +31,12 @@
    protected ?int $referralPage = null;
    protected string $rewards_table;
    protected CustomTable $referrals;
    protected CustomTable $codes;
    protected CustomTable $janeClients;
    protected CustomTable $rewards;
    protected CustomTable $treatments;
    // Default reward settings
    protected array $default_settings = [
        'referrer_reward_applies_to' => 'per_user',  // 'per_user' or 'flat_total'
@@ -48,6 +54,7 @@
    public function __construct()
    {
        $this->defineTables();
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->cache = Cache::for('referrals', WEEK_IN_SECONDS);
@@ -63,7 +70,7 @@
        add_action('jvbUserRegistered', [$this, 'processRegistrationToken'], 10, 3);
        add_action('jvb_add_token_inputs', [$this, 'addLoginInputs'], 10, 1);
        add_action('jvbUserRegistered', [$this, 'processReferral'], 10, 1);
        add_action('user_register', [$this, 'processReferral'], 10, 1);
        // Add meta boxes for admin to manage referrals
        add_action('show_user_profile', [$this, 'displayUserReferralInfo']);
@@ -97,6 +104,182 @@
        add_filter('jvb_admin_page_submission', [$this, 'handleAdminSubmission'], 10, 3);
    }
    protected function defineTables():void
    {
        $this->defineReferralsTable();
        $this->defineCodeTable();
        $this->defineJaneClientsTable();
        $this->defineRewardsTable();
        $this->defineTreatmentsTable();
    }
        protected function defineReferralsTable():void
        {
            $table = CustomTable::for('referrals');
            $table->setColumns([
                'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'from_user' => "{$table->getUserIDType()} NOT NULL",
                'to_user'   => "{$table->getUserIDType()} NOT NULL",
                'to_name'   => 'varchar(255) NOT NULL',
                'to_email'  => 'varchar(255) NOT NULL',
                'to_phone'  => 'varchar(50) NOT NULL',
                'referral_code'=> 'varchar(50) NOT NULL',
                'status'    => "ENUM('pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending'",
                'created_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
                'consulted_at'=> 'datetime DEFAULT NULL',
                'treated_at'=> 'datetime DEFAULT NULL',
                'treatment_count' => 'int DEFAULT 0',
                'notes'     => 'text DEFAULT NULL',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => 'id'],
                ['key' => 'UNIQUE', 'value' => 'to_user (`to_user`)'],
                'from_user (`from_user`)',
                'status (`status`)',
                'code (`referral_code`)',
                'date (`created_at`)',
                'consult (`consulted_at`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}referral_from_user_fk` FOREIGN KEY (`from_user`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}referral_to_user_fk` FOREIGN KEY (`to_user`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->referrals = $table;
        }
        protected function defineCodeTable():void
        {
            $table = CustomTable::for('referrals_codes');
            $table->setColumns([
                'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'user_id'   => "{$table->getUserIDType()} NOT NULL",
                'code'      => 'varchar(50) NOT NULL',
                'created_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => 'id'],
                ['key' => 'UNIQUE', 'value' => 'code (`code`)'],
                'user (`user_id`)',
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}referral_code_user_fk` FOREIGN KEY (`user_id`)
                    REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
            ]);
            $table->defineTable();
            $this->codes = $table;
        }
        protected function defineJaneClientsTable():void
        {
            $table = CustomTable::for('referrals_jane_clients');
            $table->setColumns([
                'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'patient_guid'  => 'varchar(50) NOT NULL',
                'user_id'       => "{$table->getUserIDType()} NOT NULL",
                'first_name'    => 'varchar(100) NOT NULL',
                'last_name'     => 'varchar(100) NOT NULL',
                'email'         => 'varchar(255) NOT NULL',
                'imported_at'   => 'datetime DEFAULT CURRENT_TIMESTAMP',
                'updated_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                ['key' => 'UNIQUE', 'value' => 'patient_guid (`patient_guid`)'],
                'user (`user_id`)',
                'email (`email`)',
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}jane_clients_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
            ]);
            $table->defineTable();
            $this->janeClients = $table;
        }
        protected function defineRewardsTable():void
        {
            $table = CustomTable::for('referrals_rewards');
            $table->setColumns([
                'id'                => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'referral_id'       => 'bigint(20) unsigned NOT NULL',
                'user_id'           => "{$table->getUserIDType()} NOT NULL",
                'reward_type'       => "ENUM('referrer', 'referee') NOT NULL",
                'amount'            => 'decimal(10,2) NOT NULL',
                'reward_calculation'=> "ENUM('percentage', 'fixed')",
                'status'            => "ENUM('available', 'redeemed', 'expired', 'cancelled') DEFAULT 'available'",
                'created_at'        => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
                'updated_at'        => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
                'redeemed_at'       => 'datetime DEFAULT NULL',
                'expires_at'        => 'datetime DEFAULT NULL',
                'notes'             => 'text DEFAULT NULL',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                'referral (`referral_id`)',
                'user (`user_id`)',
                'status (`status`)',
                'type (`reward_type`)'
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}reward_referral` FOREIGN KEY (`referral_id`)
                REFERENCES {$this->referrals->getFullTableName()} (`id`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}reward_user` FOREIGN KEY (`user_id`)
                REFERENCES {$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->rewards = $table;
        }
        protected function defineTreatmentsTable():void
        {
            $table = CustomTable::for('referral_treatments');
            $table->setColumns([
                'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
                'referral_id'   => 'bigint(20) unsigned NOT NULL',
                'user_id'       => "{$table->getUserIDType()} NOT NULL",
                'treatment_type'=> 'varchar(100) NOT NULL',     //Tier 1-6, Brows, etc
                'treatment_date'=> 'datetime NOT NULL',
                'invoice_number'=> 'varchar(50) DEFAULT NULL',
                'amount'        => 'decimal(10,2) DEFAULT NULL',
                'status'        => "ENUM('completed', 'no_show', 'cancelled') DEFAULT 'completed'",
                'imported_at'   => 'datetime DEFAULT CURRENT_TIMESTAMP',
            ]);
            $table->setKeys([
                ['key' => 'PRIMARY', 'value' => '(`id`)'],
                'referral (`referral_id`)',
                'user (`user_id`)',
                'date (`treatment_date`)',
                'type (`treatment_type`)',
            ]);
            $base = BASE;
            $table->setConstraints([
                "CONSTRAINT `{$base}treatment_referral` FOREIGN KEY (`referral_id`)
                REFERENCES `{$this->referrals->getFullTableName()}` (`id`) ON DELETE CASCADE",
                "CONSTRAINT `{$base}treatment_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE"
            ]);
            $table->defineTable();
            $this->treatments = $table;
        }
    public function getSettings():array
    {
        return $this->settings;
@@ -186,6 +369,26 @@
        }
    }
    public function createCode(int $user_id, string $code):string|false
    {
        $code = sanitize_title($code);
        $existing = $this->codes->get(['code' => $code]);
        if ($existing) {
            if ($existing['user_id'] !== $user_id) {
                return false;
            }
            return $code;
        }
        $success = $this->codes->insert([
            'user_id'   => $user_id,
            'code'      => $code
        ]);
        if ($success) {
            return $code;
        }
        return false;
    }
    /**
     * Generate or get existing referral code for a user
     *
@@ -193,31 +396,34 @@
     * @param string|null $custom_code Optional custom code
     * @return string|WP_Error
     */
    public function getUserReferralCode(int $user_id, ?string $custom_code = null)
    public function getUserReferralCode(int $user_id, ?string $custom_code = null):array|wp_error
    {
        $user = get_userdata($user_id);
        if (!$user) {
            return new WP_Error('invalid_user', 'User not found');
        }
        // Check if user already has a code
        $existing_code = get_user_meta($user_id, BASE . 'referral_code', true);
        if ($existing_code && !$custom_code) {
            return $existing_code;
        $existing = $this->codes->pluck('code', ['user_id' => $user_id],'created_at', 'DESC');
        if ($existing && !$custom_code) {
            return $existing;
        }
        if ($custom_code && !in_array($custom_code, $existing)) {
            $test = $this->createCode($user_id, $custom_code);
            if ($test) {
                return $this->codes->pluck('code', ['user_id' => $user_id], 'created_at', 'DESC');
            }
        } else {
            return $existing;
        }
        // Generate new code if custom provided or none exists
        $code = $custom_code ?: $this->generateReferralCode($user);
        $code = $this->generateReferralCode($user);
        // Validate uniqueness
        if ($this->isCodeTaken($code, $user_id)) {
            return new WP_Error('code_taken', 'This referral code is already in use');
        $success = $this->createCode($user_id, $code);
        if ($success) {
            return $this->codes->pluck('code', ['user_id' => $user_id], 'created_at', 'DESC');
        }
        // Save the code
        update_user_meta($user_id, BASE . 'referral_code', $code);
        return $code;
    }
@@ -251,24 +457,11 @@
     * Check if a referral code is already taken
     *
     * @param string $code
     * @param int|null $exclude_user_id
     * @return bool
     */
    protected function isCodeTaken(string $code, ?int $exclude_user_id = null): bool
    protected function isCodeTaken(string $code): bool
    {
        $args = [
            'meta_key' => BASE . 'referral_code',
            'meta_value' => $code,
            'fields' => 'ID',
            'number' => 1
        ];
        if ($exclude_user_id) {
            $args['exclude'] = [$exclude_user_id];
        }
        $users = get_users($args);
        return !empty($users);
        return (bool) $this->codes->get(['code' => $code]);
    }
    public function processRegistrationToken(int $user_id, string $email, array $data): void
@@ -295,60 +488,58 @@
     * Track a new referral when user registers
     *
     * @param int $user_id
     * @param array $userData
     * @return bool;
     */
    public function processReferral(int $user_id): bool
    public function processReferral(int $user_id, array $userData): bool
    {
        // Try to get code from user meta first (set during registration)
        $referral_code = get_user_meta($user_id, BASE . 'pending_referral_code', true);
        $referral = $this->referrals->get(['to_user' => $user_id]);
        if (empty($referral_code)) {
        if (empty($referral)) {
            $referral = $this->referrals->get(['to_email' => $userData['email']]);
        }
        if (empty($referral)) {
            // Check session/cookie if not in meta
            if (session_status() === PHP_SESSION_NONE) {
                session_start();
            }
            $referral_code = $_SESSION[BASE . 'referral_code'] ?? $_COOKIE[BASE . 'referral_code'] ?? '';
            if (!empty ($referral_code)) {
                $referral = [
                    'to_user'   => $user_id,
                    'referral_code' => $referral_code,
                    'to_email'      => $userData['user_email']
                ];
            }
        }
        if (empty($referral_code)) {
        if (empty($referral)) {
            return false; // No referral code - regular registration
        }
        // Find the referrer
        $referrer = $this->getUserByReferralCode($referral_code);
        if (!$referrer) {
            delete_user_meta($user_id, BASE . 'pending_referral_code');
        $referrer = $this->codes->pluck('user_id', ['code' => $referral['referral_code']]);
        if (empty($referrer)) {
            //This should not happen, but whatever
            return false;
        }
        $referrer = $referrer[0];
        $record = $this->referrals->findOrCreate([
            'to_user'   => $user_id,
            'referral_code' => $referral['referral_code'],
        ], [
            'from_user'     => $referrer,
            'to_email'      => $referral['to_email'],
            'to_name'       => $userData['first_name'],
//          'to_phone'      =>
            'status'        => 'pending'
        ]);
        if (!$record) {
            error_log('[ReferralManager]::processReferral Could not update record for user: '.print_r($referral, true));
            return false;
        }
        $user = get_userdata($user_id);
        // Check if referral already exists for this user
        $existing = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->referrals_table}
        WHERE referrer_id = %d AND (referee_email = %s OR referee_id = %d)",
            $referrer->ID,
            $user->user_email,
            $user_id
        ));
        if (!$existing) {
            // Create new referral record - referred_at captures registration time
            $this->wpdb->insert(
                $this->referrals_table,
                [
                    'referrer_id' => $referrer->ID,
                    'referee_id' => $user_id,
                    'referee_name' => $user->display_name,
                    'referee_email' => $user->user_email,
                    'referee_phone' => get_user_meta($user_id, BASE . 'phone', true) ?: '',
                    'referral_code' => $referral_code,
                    'status' => 'pending', // pending first treatment
                    'referred_at' => current_time('mysql') // When they registered
                ],
                ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s']
            );
        }
        // Clean up temp data
        delete_user_meta($user_id, BASE . 'pending_referral_code');
@@ -363,10 +554,10 @@
        $this->cache->flush();
        // Fire action for tracking
        do_action('jvb_referral_processed', $user_id, $referrer->ID, $referral_code);
        do_action('jvb_referral_processed', $user_id, $referrer->ID, $referral['referral_code']);
        // Send notification to referrer
        $this->sendReferrerNotification($referrer->ID, $user->display_name);
        $this->sendReferrerNotification($referrer->ID, $userData['display_name']);
        return true;
    }
inc/managers/ResponseManager.php
New file
@@ -0,0 +1,64 @@
<?php
namespace JVBase\managers;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * Response Manager
 *
 * Handles a responses
 * TODO
 */
class ResponseManager
{
    protected CustomTable $response;
    protected KarmaManager $karma;
    public function __construct()
    {
        $this->defineTables();
    }
    protected function defineTables():void
    {
        $table = CustomTable::for('responses');
        $table->setColumns([
            'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'item_id'   => "{$table->getPostIDType()} NOT NULL",
            'type'      => 'varchar(50) NOT NULL',
            'user_id'   => "{$table->getUserIdType()} NOT NULL",
            'parent_id' => 'bigint(20) unsigned DEFAULT 0',
            'response'  => 'text NOT NULL',
            'upvotes'   => 'int NOT NULL DEFAULT 0',
            'downvotes' => 'int NOT NULL DEFAULT 0',
            'karma'     => 'int NOT NULL DEFAULT 0',
            'status'    => "ENUM('publish', 'hidden','flagged','deleted') DEFAULT 'publish'",
            'is_user_deleted' => 'tinyint(1) DEFAULT 0',
            'created_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP',
            'updated_at'=> 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '`id`'],
            '`item_lookup` (`item_id`)',
            '`user_lookup` (`user_id`)',
            '`parent_lookup` (`parent_id`)',
            '`karma` (`karma`)',
            '`upvote` (`upvotes`)',
            '`downvote` (`downvotes`)',
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}responses_item` FOREIGN KEY (`item_id`)
            REFERENCES `{$table->getPostTable()}` (`ID`) ON DELETE CASCADE",
        ]);
        $table->defineTable();
        $this->response = $table;
        $this->karma = KarmaManager::for('responses', 'responses');
    }
}
inc/managers/RoleManager.php
@@ -16,9 +16,7 @@
    public function __construct()
    {
       $this->roles = array_keys(array_map(function ($instance) {
           return $instance->slug;
       }, Registrar::getRegistered('user')));
       $this->roles = Registrar::getRegistered('user');
       $this->content = array_map(function($content) {
           $registrar = Registrar::getInstance($content);
@@ -475,6 +473,15 @@
        $user->add_cap(BASE . 'can_own_' . $termID);
        $user->add_cap(BASE . 'can_manage_' . $termID);
        $owners = get_term_meta($termID, BASE.'owners', true);
        if (empty($owners)) {
            $owners = [];
        }
        $owners[] = $userID;
        $owners = array_unique($owners);
        update_term_meta($termID, BASE.'owners', $owners);
        do_action(BASE . 'granted_ownership', $userID, $termID, $taxonomy);
        return true;
@@ -500,6 +507,16 @@
            return false;
        }
        $owners = get_term_meta($termID, BASE.'owners', true);
        if (empty($owners)) {
            $owners = [];
        }
        if (in_array($userID, $owners)) {
            unset($owners[array_search($userID, $owners)]);
        }
        $owners = array_unique($owners);
        update_term_meta($termID, BASE.'owners', $owners);
        $user->remove_cap(BASE . 'can_own_' . $termID);
        do_action(BASE . 'revoked_ownership', $userID, $termID, $taxonomy);
@@ -537,6 +554,14 @@
        $user->add_cap(BASE . 'can_manage_' . $termID);
        $managers = get_term_meta($termID, BASE.'managers', true);
        if (empty($managers)) {
            $managers = [];
        }
        $managers[] = $userID;
        $managers = array_unique($managers);
        update_term_meta($termID, BASE.'managers', $managers);
        do_action(BASE . 'granted_management', $userID, $termID, $taxonomy);
        return true;
@@ -564,6 +589,16 @@
        $user->remove_cap(BASE . 'can_manage_' . $termID);
        $managers = get_term_meta($termID, BASE.'managers', true);
        if (empty($managers)) {
            $managers = [];
        }
        if (in_array($userID, $managers)) {
            unset($managers[array_search($userID, $managers)]);
        }
        $managers = array_unique($managers);
        update_term_meta($termID, BASE.'managers', $managers);
        do_action(BASE . 'revoked_management', $userID, $termID, $taxonomy);
        return true;
inc/managers/SEO/BreadcrumbManager.php
@@ -121,10 +121,10 @@
                    'name' => $contentRegistrar->getConfig('breadcrumbs')['title']??$contentRegistrar->getPlural(),
                    'url'  => get_post_type_archive_link(jvbCheckBase($content)),
                ];
                $crumbs[] = [
                    'name' => 'By ' . $registrar->getSingular(),
                    'url'  => false,
                ];
//              $crumbs[] = [
//                  'name' => 'By ' . $registrar->getSingular(),
//                  'url'  => false,
//              ];
            }
        }
@@ -150,9 +150,11 @@
        // Add directory if exists
        $content = jvbNoBase($post->post_type);
        $registrar = Registrar::getInstance($content);
        $crumbConfig = false;
        if ($registrar){
            $crumbConfig = $registrar->getConfig('breadcrumbs');
        }
        if($registrar && $registrar->hasFeature('show_directory')) {
            $directory = JVB()->directories()?->directories($content)??[];
            if (!empty($directory)) {
@@ -209,7 +211,7 @@
        } elseif (is_post_type_archive() && $registrar && $registrar->hasFeature('show_directory')) {
            $crumbs[] = [
                'name' => $registrar->getConfig('breadcrumb')['title'] ?? $registrar->getPlural(),
                'name' => $registrar->getConfig('breadcrumbs')['title'] ?? $registrar->getPlural(),
                'url'  => get_post_type_archive_link($type)
            ];
        }
@@ -366,29 +368,33 @@
        }
    }
    public function addTaxToCrumbs(array $crumbs, string $taxonomy):array
    public function addTaxToCrumbs(array $crumbs, string|array $taxonomy):array
    {
        $ID = get_the_ID();
        $taxonomy = jvbCheckBase($taxonomy);
        $terms = get_the_terms($ID, $taxonomy);
        if ($terms && !is_wp_error($terms)) {
            $term = $terms[0];
            $ancestors = get_ancestors($term->term_id, $taxonomy, 'taxonomy');
            $ancestors = array_reverse($ancestors);
            foreach ($ancestors as $ancestor) {
                $aTerm = get_term($ancestor, $taxonomy);
                if ($aTerm && !is_wp_error($aTerm)) {
                    $crumbs[] = [
                        'name' => $aTerm->name,
                        'url'   => get_term_link($ancestor, $taxonomy)
                    ];
        $taxonomies = is_string($taxonomy) ? [$taxonomy] : $taxonomy;
        foreach ($taxonomies as $tax) {
            $taxonomy = jvbCheckBase($tax);
            $terms = get_the_terms($ID, $taxonomy);
            if ($terms && !is_wp_error($terms)) {
                $term = $terms[0];
                $ancestors = get_ancestors($term->term_id, $taxonomy, 'taxonomy');
                $ancestors = array_reverse($ancestors);
                foreach ($ancestors as $ancestor) {
                    $aTerm = get_term($ancestor, $taxonomy);
                    if ($aTerm && !is_wp_error($aTerm)) {
                        $crumbs[] = [
                            'name' => $aTerm->name,
                            'url'   => get_term_link($ancestor, $taxonomy)
                        ];
                    }
                }
                $crumbs[] = [
                    'name' => html_entity_decode($term->name),
                    'url'   => get_term_link($term, $taxonomy)
                ];
            }
            $crumbs[] = [
                'name' => html_entity_decode($term->name),
                'url'   => get_term_link($term, $taxonomy)
            ];
        }
        return $crumbs;
    }
}
inc/managers/SEO/render/DataType/Date.php
@@ -26,13 +26,9 @@
        return $this->date;
    }
    /**
     * @throws \DateMalformedStringException
     */
    public function setDate(string $date):void
    {
        $time = new \DateTime(strtotime($date));
        $time = $time->format('c');
        $time = date('c', strtotime($date));
        if ($time){
            $this->date = $time;
        }
inc/managers/SEO/render/DataType/Time.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers\SEO\render\DataType;
use DateMalformedStringException;
use DateTime;
use JVBase\meta\Sanitizer;
if (!defined('ABSPATH')) {
    exit;
}
@@ -20,14 +20,7 @@
        return $this->time;
    }
    /**
     * @throws DateMalformedStringException
     */
    public function setTime(string $time):void {
        $time = new DateTime(strtotime($time));
        $time = $time->format('H:i:s');
        if ($time){
            $this->time = $time;
        }
        $this->time = Sanitizer::sanitize($time, ['type'=> 'time']);
    }
}
inc/managers/SEO/render/SchemaOutput.php
@@ -1,13 +1,11 @@
<?php
namespace JVBase\managers\SEO\render;
use JVBase\inc\managers\SEO\render\Thing\Intangible\ItemList\OfferCatalog;
use JVBase\managers\Cache;
use JVBase\managers\SEO\BreadcrumbManager;
use JVBase\managers\SEO\render\Thing\CreativeWork\WebSite;
use JVBase\managers\SEO\render\Thing\Intangible\OfferCatalog;
use JVBase\managers\SEO\render\Thing\Intangible\Service;
use JVBase\managers\SEO\render\Thing\Organization\LocalBusiness\LocalBusiness;
use JVBase\meta\Meta;
use JVBase\registrar\config\seo\Resolver;
use JVBase\registrar\Registrar;
@@ -25,8 +23,9 @@
    {
        $schema = [];
        $schema[] = $this->buildWebsiteSchema(false);
        if (is_front_page()) {
            $schema[] = $this->buildWebsiteSchema(true);
            $test = $this->buildOrganizationSchema();
            if (!empty($test)) {
                $schema[] = $test;
@@ -35,17 +34,17 @@
        if (is_singular($this->types)) {
            $type = get_post_type();
            $registrar = Registrar::getInstance($type);
            if ($registrar && !empty($registrar->getConfig('seo')['schema']??[])) {
            if ($registrar) {
                $seo = $registrar->getSEO();
                $schema[] = $seo->schema()->outputSingularSchema();
            }
        }elseif (is_post_type_archive($this->types)) {
            error_log('It is a post type archive');
            $type = get_queried_object();
            $type = $type->name;
            $registrar = Registrar::getInstance($type);
            if ($registrar && !empty($registrar->getConfig('seo')['schema']??[])) {
            if ($registrar) {
                $seo = $registrar->getSEO();
                $schema[] =$seo->schema()->outputArchiveSchema();
            }
@@ -53,7 +52,7 @@
            $type = get_queried_object();
            $type = jvbNoBase($type->taxonomy);
            $registrar = Registrar::getInstance($type);
            if ($registrar && !empty($registrar->getConfig('seo')['schema']??[])) {
            if ($registrar ) {
                $seo = $registrar->getSEO();
                error_log('SEO: '.print_r($seo->schema(), true));
                $schema[] = $seo->schema()->outputArchiveSchema();
@@ -66,10 +65,13 @@
        if (is_page($isContent)){
            $type = get_post_meta(get_the_id(), BASE.'for_type', true);
            error_log('Type: '.print_r($type, true));
            $registrar = Registrar::getInstance($type);
            if ($registrar) {
                $schema[] = $registrar->getSEO()->schema()->outputContentTaxArchiveSchema();
            if (!empty($type)) {
                $registrar = Registrar::getInstance($type);
                if ($registrar) {
                    $schema[] = $registrar->getSEO()->schema()->outputContentTaxArchiveSchema();
                }
            }
        }
@@ -80,19 +82,22 @@
        }
        if (!empty($schema)) {
            $website = JVB()->schemaHelper()::schema('website');
            if (!empty($website)) {
                if (JVB_TESTING) {
                    Cache::for('websiteSchema')->flush();
                }
                $website = Cache::for('websiteSchema')->remember(
                    'schema',
                    function () {
                        return $this->websiteSchema();
            if (!is_front_page()) {
                $website = JVB()->schemaHelper()::schema('website');
                if (!empty($website)) {
                    if (JVB_TESTING) {
                        Cache::for('websiteSchema')->flush();
                    }
                );
                array_unshift($schema, $website);
                    $website = Cache::for('websiteSchema')->remember(
                        'schema',
                        function () {
                            return $this->websiteSchema();
                        }
                    );
                    array_unshift($schema, $website);
                }
            }
            $schema = [
                '@context'      => 'https://schema.org',
                '@graph'        => $schema
@@ -113,8 +118,8 @@
        if (!$website->getName() || empty($website->getName())){
            $website->setName(get_bloginfo('name'));
        }
        if(!$website->getCreator() || empty($website->getCreator())) {
            $website->setCreator($this->getCreator());
        if(!$website->getCreator() || empty($website->getCreator(true))) {
            $website->setCreator($this->getCreator(true));
        }
        return $website->outputSchema();
    }
@@ -125,9 +130,17 @@
        if (empty($schema)) {
            return;
        }
//      $encoded =  wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
//      $encoded =  wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT, 1024);
        $encoded = wp_json_encode($schema, JSON_UNESCAPED_SLASHES, 512);
        if ($encoded === false) {
            error_log('wp_json_encode failed: ' . json_last_error_msg());
            return;
        }
        echo "\n<!-- SEO Schema by JakeVan -->\n";
        echo '<script type="application/ld+json">' . "\n";
        echo wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
        echo $encoded;
        echo "\n" . '</script>' . "\n";
        echo "\n" . '<!-- / SEO Schema by JakeVan -->'."\n";
    }
@@ -185,7 +198,6 @@
        return Cache::for('websiteSchema')->remember(
            'full',
            function () use ($storedWebsite) {
                error_log('StoredWebsite: '.print_r($storedWebsite, true));
                $website = JVB()->schemaHelper()::classFromConfig($storedWebsite);
                if (!$website->getName() || empty($website->getName())) {
                    $website->setName(get_bloginfo('name'));
@@ -217,71 +229,78 @@
    }
    public function getCreator(bool $reference = false):LocalBusiness
    public function getCreator(bool $reference = false, bool $jakeVan = false):LocalBusiness
    {
        if (JVB_TESTING){
            Cache::for('JakeVanCreator')->flush();
        }
        if ($reference) {
        if ($reference && $jakeVan) {
            return Cache::for('JakeVanCreator')->remember(
                'reference',
                function () {
                    $creator = new LocalBusiness();
                    $creator->setId('jakevan');
                    $creator->setName('JakeVan');
                    $creator->setAlternateName('Jake Vanderwerf');
                    $creator->setUrl('https://jakevan.ca/');
                    $creator->setDescription('Let\'s bring your idea to life.');
                    return $creator;
                }
            );
        }
        if ($jakeVan) {
            return Cache::for('JakeVanCreator')->remember(
                'full',
                function () {
                    $creator = new LocalBusiness();
                    $creator->setName('JakeVan');
                    $creator->setId('https://jakevan.ca/#localbusiness');
                    $creator->setAlternateName(['Jake Vanderwerf', 'Rooted Romantic', 'Jacob Vanderwerf']);
                    $creator->setUrl('https://jakevan.ca');
                    $creator->setSameAs([
                        'https://bsky.app/profile/jakevan.ca',
                    ]);
                    $creator->setAreaServed(['Edmonton, Alberta', 'Alberta', 'Canada']);
                    $offers = new OfferCatalog();
                    $offers->setName('Services');
                    $offers->setUrl('https://jakevan.ca/services');
        return Cache::for('JakeVanCreator')->remember(
            'full',
            function () {
                $creator = new LocalBusiness();
                $creator->setName('JakeVan');
                $creator->setId('https://jakevan.ca/#localbusiness');
                $creator->setAlternateName(['Jake Vanderwerf', 'Rooted Romantic', 'Jacob Vanderwerf']);
                $creator->setUrl('https://jakevan.ca');
                $creator->setSameAs([
                    'https://bsky.app/profile/jakevan.ca',
                ]);
                $creator->setAreaServed(['Edmonton, Alberta', 'Alberta', 'Canada']);
                $offers = new OfferCatalog();
                $offers->setName('Services');
                $offers->setUrl('https://jakevan.ca/services');
                    $graphicDesign = new Service();
                    $graphicDesign->setName('Graphic Design');
                    $graphicDesign->setUrl('https://jakevan.ca/services/design/');
                    $graphicDesign->setDescription('From print to digital design.');
                    $websiteDesign = new Service();
                    $websiteDesign->setName('Development');
                    $websiteDesign->setUrl('https://jakevan.ca/services/development/');
                    $websiteDesign->setDescription('From basic websites to custom functionality.');
                    $strategy = new Service();
                    $strategy->setName('Strategy');
                    $strategy->setUrl('https://jakevan.ca/services/strategy/');
                    $strategy->setDescription('From developing your business plan to SEO.');
                    $art = new Service();
                    $art->setName('Art');
                    $art->setUrl('https://jakevan.ca/services/art/');
                    $art->setDescription('From unique, custom, handmade pieces to small-scale wholesale.');
                $graphicDesign = new Service();
                $graphicDesign->setName('Graphic Design');
                $graphicDesign->setUrl('https://jakevan.ca/services/design/');
                $graphicDesign->setDescription('From print to digital design.');
                $websiteDesign = new Service();
                $websiteDesign->setName('Development');
                $websiteDesign->setUrl('https://jakevan.ca/services/development/');
                $websiteDesign->setDescription('From basic websites to custom functionality.');
                $strategy = new Service();
                $strategy->setName('Strategy');
                $strategy->setUrl('https://jakevan.ca/services/strategy/');
                $strategy->setDescription('From developing your business plan to SEO.');
                $art = new Service();
                $art->setName('Art');
                $art->setUrl('https://jakevan.ca/services/art/');
                $art->setDescription('From unique, custom, handmade pieces to small-scale wholesale.');
                $offers->setItemListElement([
                    $graphicDesign,
                    $websiteDesign,
                    $strategy,
                    $art
                ]);
                    $offers->setItemListElement([
                        $graphicDesign,
                        $websiteDesign,
                        $strategy,
                        $art
                    ]);
                $creator->setHasOfferCatalog($offers);
                return $creator;
            }
        );
                    $creator->setHasOfferCatalog($offers);
                    return $creator;
                }
            );
        }
        if ($reference) {
            return $this->getOptionSchemaReference('organization');
        }
        $config = JVB()->schemaHelper()::schema('organization');
        return JVB()->schemaHelper()::classFromConfig($config);
    }
    public function getOptionSchemaReference(string $option):mixed
@@ -294,8 +313,8 @@
            error_log('Attempted to get schema reference for: '.$option.', but it does not exist');
            return null;
        }
        $action = BASE.ucfirst($option).'Schema';
        $stored = JVB()->schemaHelper()::reference($action);
        $stored = JVB()->schemaHelper()::reference($option);
        if (empty($stored)){
            error_log('Attempted to get schema reference for: '.$option.', but defaults not set.');
@@ -308,7 +327,7 @@
        }
        $class = new $type();
        unset($stored['type']);
        $minimal = apply_filters($action.'Reference', ['name', 'url', 'sameAs', 'logo']);
        $minimal = apply_filters(BASE.'OrganizationReference', ['name', 'url', 'sameAs', 'logo']);
        foreach ($minimal as $property) {
            $method = 'set'.ucfirst($property);
@@ -316,6 +335,7 @@
            if (!$value) {continue;}
            if (str_contains($value, '{{')) {
                $meta = null;
                $value = Resolver::resolve($property, $value);
            }
            $class->$method($value);
@@ -326,6 +346,7 @@
    public function buildOrganizationSchema():array
    {
        $config = JVB()->schemaHelper()::schema('organization');
        $class = JVB()->schemaHelper()::classFromConfig($config);
        return ($class)? $class->outputSchema() : [];
    }
inc/managers/SEO/render/Thing/Action.php
File was deleted
inc/managers/SEO/render/Thing/Action/Action.php
New file
@@ -0,0 +1,30 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\Action;
use JVBase\managers\SEO\render\Thing\Thing;
use JVBase\managers\SEO\render\Traits\_Properties\actionProcessTrait;
use JVBase\managers\SEO\render\Traits\_Properties\actionStatusTrait;
use JVBase\managers\SEO\render\Traits\_Properties\agentTrait;
use JVBase\managers\SEO\render\Traits\_Properties\descriptionTrait;
use JVBase\managers\SEO\render\Traits\_Properties\endTimeTrait;
use JVBase\managers\SEO\render\Traits\_Properties\errorTrait;
use JVBase\managers\SEO\render\Traits\_Properties\instrumentTrait;
use JVBase\managers\SEO\render\Traits\_Properties\locationTrait;
use JVBase\managers\SEO\render\Traits\_Properties\nameTrait;
use JVBase\managers\SEO\render\Traits\_Properties\objectTrait;
use JVBase\managers\SEO\render\Traits\_Properties\participantTrait;
use JVBase\managers\SEO\render\Traits\_Properties\providerTrait;
use JVBase\managers\SEO\render\Traits\_Properties\resultTrait;
use JVBase\managers\SEO\render\Traits\_Properties\startTimeTrait;
use JVBase\managers\SEO\render\Traits\_Properties\targetTrait;
if (!defined('ABSPATH')) {
    exit;
}
class Action extends Thing {
    use  actionProcessTrait, actionStatusTrait, agentTrait,
        endTimeTrait, errorTrait, instrumentTrait, locationTrait,
        objectTrait, participantTrait, providerTrait, resultTrait,
        startTimeTrait, targetTrait;
}
inc/managers/SEO/render/Thing/Action/CommunicateAction.php
New file
@@ -0,0 +1,14 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\Action;
use JVBase\managers\SEO\render\Traits\_Properties\aboutTrait;
use JVBase\managers\SEO\render\Traits\_Properties\inLanguageTrait;
if (!defined('ABSPATH')) {
    exit;
}
class CommunicateAction extends InteractAction {
    use aboutTrait, inLanguageTrait;
    //also has "recipient"
}
inc/managers/SEO/render/Thing/Action/InteractAction.php
New file
@@ -0,0 +1,24 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\Action;
use JVBase\managers\SEO\render\Traits\_Properties\actionProcessTrait;
use JVBase\managers\SEO\render\Traits\_Properties\actionStatusTrait;
use JVBase\managers\SEO\render\Traits\_Properties\agentTrait;
use JVBase\managers\SEO\render\Traits\_Properties\endTimeTrait;
use JVBase\managers\SEO\render\Traits\_Properties\errorTrait;
use JVBase\managers\SEO\render\Traits\_Properties\instrumentTrait;
use JVBase\managers\SEO\render\Traits\_Properties\locationTrait;
use JVBase\managers\SEO\render\Traits\_Properties\objectTrait;
use JVBase\managers\SEO\render\Traits\_Properties\participantTrait;
use JVBase\managers\SEO\render\Traits\_Properties\providerTrait;
use JVBase\managers\SEO\render\Traits\_Properties\resultTrait;
use JVBase\managers\SEO\render\Traits\_Properties\startTimeTrait;
use JVBase\managers\SEO\render\Traits\_Properties\targetTrait;
if (!defined('ABSPATH')) {
    exit;
}
class InteractAction extends Action {
}
inc/managers/SEO/render/Thing/Action/OrganizeAction.php
New file
@@ -0,0 +1,10 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\Action;
if (!defined('ABSPATH')) {
    exit;
}
class OrganizeAction extends Action {
}
inc/managers/SEO/render/Thing/Action/PlanAction.php
New file
@@ -0,0 +1,13 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\Action;
use JVBase\inc\managers\SEO\render\Traits\_Properties\scheduledTimeTrait;
if (!defined('ABSPATH')) {
    exit;
}
class PlanAction extends OrganizeAction {
    use scheduledTimeTrait;
}
inc/managers/SEO/render/Thing/Action/ScheduleAction.php
New file
@@ -0,0 +1,10 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\Action;
if (!defined('ABSPATH')) {
    exit;
}
class ScheduleAction extends PlanAction {
}
inc/managers/SEO/render/Thing/Action/_setup.php
New file
@@ -0,0 +1,9 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/Action.php');
    require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/InteractAction.php');
        require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/CommunicateAction.php');
    require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/OrganizeAction.php');
        require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/PlanAction.php');
            require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/ScheduleAction.php');
inc/managers/SEO/render/Thing/CreativeWork/Comment/Answer.php
New file
@@ -0,0 +1,14 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment;
use JVBase\inc\managers\SEO\render\Traits\_Properties\answerExplanationTrait;
use JVBase\inc\managers\SEO\render\Traits\_Properties\parentItemTrait;
if (!defined('ABSPATH')) {
    exit;
}
class Answer extends Comment
{
    use parentItemTrait, answerExplanationTrait;
}
inc/managers/SEO/render/Thing/CreativeWork/Comment/Comment.php
New file
@@ -0,0 +1,17 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment;
use JVBase\inc\managers\SEO\render\Traits\_Properties\downvoteCountTrait;
use JVBase\inc\managers\SEO\render\Traits\_Properties\parentItemTrait;
use JVBase\inc\managers\SEO\render\Traits\_Properties\sharedContentTrait;
use JVBase\inc\managers\SEO\render\Traits\_Properties\upvoteCountTrait;
use JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork;
if (!defined('ABSPATH')) {
    exit;
}
class Comment extends CreativeWork
{
    use downvoteCountTrait, parentItemTrait, sharedContentTrait, upvoteCountTrait;
}
inc/managers/SEO/render/Thing/CreativeWork/Comment/Question.php
New file
@@ -0,0 +1,16 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment;
use JVBase\inc\managers\SEO\render\Traits\_Properties\acceptedAnswerTrait;
use JVBase\inc\managers\SEO\render\Traits\_Properties\answerCountTrait;
use JVBase\inc\managers\SEO\render\Traits\_Properties\parentItemTrait;
use JVBase\inc\managers\SEO\render\Traits\_Properties\suggestedAnswerTrait;
if (!defined('ABSPATH')) {
    exit;
}
class Question extends Comment
{
    use acceptedAnswerTrait, answerCountTrait, parentItemTrait, suggestedAnswerTrait;
}
inc/managers/SEO/render/Thing/CreativeWork/Comment/_setup.php
New file
@@ -0,0 +1,4 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Comment/Comment.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Comment/Answer.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Comment/Question.php');
inc/managers/SEO/render/Thing/CreativeWork/MediaObject/MediaObject.php
@@ -55,4 +55,11 @@
     * @var Date|DateTime Date (including time if available) when this media object was uploaded to this site.
     */
    protected Date|DateTime $uploadDate;
    public function setUploadDate(string|DateTime $date):void
    {
        if (is_string($date)) {
            $date = new DateTime($date);
        }
        $this->uploadDate = $date;
    }
}
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage.php
File was deleted
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/CollectionPage.php
New file
@@ -0,0 +1,15 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage;
use JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\WebPage;
if (!defined('ABSPATH')) {
    exit;
}
class CollectionPage extends WebPage
{
}
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/ImageGallery.php
New file
@@ -0,0 +1,13 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage;
if (!defined('ABSPATH')) {
    exit;
}
class ImageGallery extends MediaGallery
{
}
inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/MediaGallery.php
New file
@@ -0,0 +1,15 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage;
use JVBase\managers\SEO\render\Traits\_Properties\additionalPropertyTrait;
if (!defined('ABSPATH')) {
    exit;
}
class MediaGallery extends CollectionPage
{
    use additionalPropertyTrait;
}
inc/managers/SEO/render/Thing/CreativeWork/WebPage/_setup.php
@@ -2,7 +2,9 @@
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/WebPage.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/AboutPage.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/CheckoutPage.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/CollectionPage.php');
    require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/MediaGallery.php');
    require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/CollectionPage/ImageGallery.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/ContactPage.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/FAQPage.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/ItemPage.php');
inc/managers/SEO/render/Thing/CreativeWork/_setup.php
@@ -18,3 +18,4 @@
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebSite.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/MediaObject/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/WebPage/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/Comment/_setup.php');
inc/managers/SEO/render/Thing/Intangible/Enumeration/DayOfWeek.php
@@ -11,7 +11,7 @@
}
class DayOfWeek extends Enumeration {
    protected string $day;
    protected string|array $day;
    protected array $allowedDay = [
        'monday'    => 'https://schema.org/Monday',
        'tuesday'   => 'https://schema.org/Tuesday',
@@ -22,4 +22,21 @@
        'sunday'    => 'https://schema.org/Sunday',
        'publicHolidays' => 'https://schema.org/PublicHolidays'
    ];
    public function setDay(array|string $day) {
        if (!is_array($day)) {
            $day = [$day];
        }
        $day = array_filter($day, function($d) {
            if (!array_key_exists(strtolower($d), $this->allowedDay)) {
                error_log('Invalid day attempted: '.$d);
                return false;
            }
            return true;
        });
        if (empty($day)) {
            return;
        }
        $this->day = count($day) === 1 ? $day[0] : $day;
    }
}
inc/managers/SEO/render/Thing/Intangible/ItemList/OfferCatalog.php
File was renamed from inc/managers/SEO/render/Thing/Intangible/OfferCatalog.php
@@ -1,5 +1,5 @@
<?php
namespace JVBase\managers\SEO\render\Thing\Intangible;
namespace JVBase\inc\managers\SEO\render\Thing\Intangible\ItemList;
use JVBase\managers\SEO\render\Thing\Intangible\ItemList\ItemList;
inc/managers/SEO/render/Thing/Intangible/ItemList/_setup.php
@@ -1,4 +1,4 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ItemList/ItemList.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ItemList/BreadcrumbList.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/ItemList/OfferCatalog.php');
inc/managers/SEO/render/Thing/Intangible/StructuredValue/InteractionCounter.php
@@ -2,13 +2,6 @@
namespace JVBase\managers\SEO\render\Thing\Intangible\StructuredValue;
use JVBase\managers\SEO\render\DataType\DateTime;
use JVBase\managers\SEO\render\DataType\Time;
use JVBase\managers\SEO\render\Thing\Action;
use JVBase\managers\SEO\render\Thing\CreativeWork\WebSite;
use JVBase\managers\SEO\render\Thing\Intangible\ContactPoint\PostalAddress;
use JVBase\managers\SEO\render\Thing\Intangible\VirtualLocation;
use JVBase\managers\SEO\render\Thing\Place\Place;
use JVBase\managers\SEO\render\Traits\_Properties\endTimeTrait;
use JVBase\managers\SEO\render\Traits\_Properties\interactionServiceTrait;
use JVBase\managers\SEO\render\Traits\_Properties\interactionTypeTrait;
inc/managers/SEO/render/Thing/Intangible/StructuredValue/PriceSpecification.php
@@ -8,6 +8,7 @@
use JVBase\managers\SEO\render\Traits\_Properties\minPriceTrait;
use JVBase\managers\SEO\render\Traits\_Properties\priceCurrencyTrait;
use JVBase\managers\SEO\render\Traits\_Properties\priceTrait;
use JVBase\managers\SEO\render\Traits\_Properties\unitTextTrait;
use JVBase\managers\SEO\render\Traits\_Properties\validFromTrait;
use JVBase\managers\SEO\render\Traits\_Properties\validThroughTrait;
@@ -18,5 +19,5 @@
class PriceSpecification extends StructuredValue {
    use eligibleQuantityTrait, eligibleTransactionVolumeTrait, maxPriceTrait,
        membershipPointsEarnedTrait, minPriceTrait, priceTrait, priceCurrencyTrait,
        validFromTrait, validThroughTrait;
        validFromTrait, validThroughTrait, unitTextTrait;
}
inc/managers/SEO/render/Thing/Intangible/StructuredValue/UnitPriceSpecification.php
New file
@@ -0,0 +1,14 @@
<?php
namespace JVBase\inc\managers\SEO\render\Thing\Intangible\StructuredValue;
use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\PriceSpecification;
use JVBase\managers\SEO\render\Traits\_Properties\unitTextTrait;
if (!defined('ABSPATH')) {
    exit;
}
class UnitPriceSpecification extends PriceSpecification {
    use unitTextTrait;
    //TODO And others.
}
inc/managers/SEO/render/Thing/Intangible/StructuredValue/_setup.php
@@ -12,6 +12,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/QuantitativeValue.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/ServicePeriod.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/TypeAndQuantityNode.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/UnitPriceSpecification.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/WarrantyPromise.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/StructuredValue/LocationFeatureSpecification.php');
inc/managers/SEO/render/Thing/Intangible/_setup.php
@@ -25,7 +25,6 @@
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Offer.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/AggregateOffer.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/OfferCatalog.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/PaymentMethod.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Schedule.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/Service.php');
inc/managers/SEO/render/Thing/_setup.php
@@ -1,6 +1,7 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Thing.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Action/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/CreativeWork/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Event/_setup.php');
require(JVB_DIR . '/inc/managers/SEO/render/Thing/Intangible/_setup.php');
inc/managers/SEO/render/Traits/ThingSchema.php
@@ -5,9 +5,12 @@
    exit;
}
use JVBase\managers\Cache;
use JVBase\managers\SEO\render\DataType\Date;
use JVBase\managers\SEO\render\DataType\DateTime;
use JVBase\managers\SEO\render\DataType\Time;
use JVBase\managers\SEO\render\Thing\CreativeWork\MediaObject\ImageObject;
use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\PropertyValue;
use JVBase\managers\SEO\render\Thing\Thing;
use JVBase\managers\SEO\render\Traits\_Properties\additionalTypeTrait;
use JVBase\managers\SEO\render\Traits\_Properties\alternateNameTrait;
@@ -38,7 +41,7 @@
    {
        global $wp;
        $current = home_url( add_query_arg( $_GET, $wp->request ) );
        $id = (isset($this->id)) ? $this->id : $current.'#'.strtolower($this->getTypeName());
        $id = (isset($this->id)) ? $this->id : $current.'/#'.strtolower($this->getTypeName());
        $elements = array_map(
            function ($value) {
@@ -61,8 +64,9 @@
                } else if (is_a($value, Time::class)) {
                    $value = $value->getTime();
                }else if (!is_string($value)) {
                    error_log('Normal value? '.print_r($value, true));
                    if (JVB_TESTING && !is_numeric($value)) {
                        error_log('Normal value? '.print_r($value, true));
                    }
                }
                return $value;
@@ -114,8 +118,8 @@
    public function setId(string $id):void
    {
        if (!filter_var($id, FILTER_VALIDATE_URL)) {
            error_log('[SEO]Could not set id: '.$id.'. Should be a valid URL');
            return;
            global $wp;
            $id = home_url( add_query_arg( $_GET, $wp->request ) ).'/#'.sanitize_title($id);
        }
        $this->id = $id;
    }
@@ -126,4 +130,35 @@
            unset($this->$property);
        }
    }
    public static function createImageFromID(int $ID):ImageObject|false
    {
        $cache = Cache::for('schemaImage')->connect('post');
        return $cache->remember(
            $ID,
            function() use($ID) {
                $imagePost = get_post($ID);
                if (!$imagePost) {
                    return false;
                }
                $image = wp_get_attachment_image_src($ID,'full');
                if (empty($image)) {
                    return false;
                }
                $imageObject = new ImageObject();
                $imageObject->setUploadDate($imagePost->post_date);
                $imageObject->setWidth($image[1]);
                $imageObject->setHeight($image[2]);
                $imageObject->setContentUrl($image[0]);
                $caption = wp_get_attachment_caption($ID);
                if (!empty($caption)) {
                    $imageObject->setCaption($caption);
                }
                return $imageObject;
            }
        );
    }
}
inc/managers/SEO/render/Traits/_Helpers/_setup.php
@@ -1,2 +1,3 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Helpers/arrayHelper.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Helpers/enumerationHelper.php');
inc/managers/SEO/render/Traits/_Helpers/arrayHelper.php
@@ -1,13 +1,20 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Helpers;
use JVBase\base\SchemaHelper;
if (!defined('ABSPATH')) {
    exit;
}
trait arrayHelper {
    protected function classArray(string $property, array $array, string $className):array
    {
        return array_filter($array, function ($item) use ($property, $className) {
        return array_filter(array_map(function ($item) {
            if (is_array($item) && array_key_exists('type', $item)) {
                $item = SchemaHelper::classFromConfig($item);
            }
            return $item;
        },$array), function ($item) use ($property, $className) {
            $test = class_exists($className) && is_a($item, $className);
            if (!$test) {
                error_log('[SEO]Item property '.$property.' is not an instance of '.$className);
@@ -17,7 +24,14 @@
    }
    protected function mixedArray(string $property, array $array, array $allowedTypes):array
    {
        return array_filter($array, function($item) use ($property, $allowedTypes) {
        return array_filter(array_map(function($item) {
            if (is_array($item) && array_key_exists('type', $item)) {
                return JVB()->schemaHelper()::classFromConfig($item);
            } else {
                return $item;
            }
        },$array), function($item) use ($property, $allowedTypes) {
            $test = in_array(gettype($item), $allowedTypes) || $this->testClasses($item, $allowedTypes);
            if (!$test) {
                error_log('[SEO]Item property '.$property.' is not an allowed type: '.print_r($item, true));
inc/managers/SEO/render/Traits/_Helpers/enumerationHelper.php
New file
@@ -0,0 +1,18 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Helpers;
if (!defined('ABSPATH')) {
    exit;
}
trait enumerationHelper {
    public function checkEnumArray(array $toCheck, string $property, mixed $class):array
    {
        if (!property_exists($class, $property)) {
            error_log('[enumHelper]::checkEnumArray - invalid property of class: '.$property.', '.print_r($class, true));
            return [];
        }
        $toCheck = array_map(function($check) use ($property, $class){
        }, $toCheck);
    }
}
inc/managers/SEO/render/Traits/_Properties/_setup.php
@@ -1,6 +1,7 @@
<?php
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/aboutTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/abstractTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/acceptedAnswerTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/acceptedPaymentMethodTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/acceptsReservationsTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/accountablePersonTrait.php');
@@ -26,6 +27,8 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/alternativeHeadlineTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/amenityFeatureTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/amountOfThisGoodTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/answerCountTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/answerExplanationTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/applicableCountryTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/areaServedTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/artEditionTrait.php');
@@ -125,6 +128,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/displayLocationTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/dissolutionDateTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/doorTimeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/downvoteCountTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/dunsTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/durationOfWarrantyTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/durationTrait.php');
@@ -297,6 +301,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/ownsTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/parentOrganizationTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/parentTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/parentItemTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/participantTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/partySizeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/patternTrait.php');
@@ -365,6 +370,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/reviewTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/sameAsTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/saturatedFatContentTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/scheduledTimeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/scheduleTimezoneTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/sdDatePublishedTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/sdLicenseTrait.php');
@@ -381,6 +387,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/serviceTypeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/serviceUrlTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/servingSizeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/sharedContentTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/siblingTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/significantLinkTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/sizeTrait.php');
@@ -405,6 +412,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/subOrganizationTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/successorOfTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/sugarContentTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/suggestedAnswerTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/suitableForDietTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/superEventTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/targetTrait.php');
@@ -429,6 +437,7 @@
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/unitCodeTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/unitTextTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/unsaturatedFatContentTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/upvoteCountTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/urlTemplateTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/urlTrait.php');
require(JVB_DIR . '/inc/managers/SEO/render/Traits/_Properties/userInteractionCountTrait.php');
inc/managers/SEO/render/Traits/_Properties/acceptedAnswerTrait.php
New file
@@ -0,0 +1,24 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
use JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Answer;
use JVBase\managers\SEO\render\Thing\Intangible\ItemList\ItemList;
if (!defined('ABSPATH')) {
    exit;
}
trait acceptedAnswerTrait {
    /**
     * @var Answer|ItemList The answer(s) that has been accepted as best, typically on a Question/Answer site. Sites vary in their selection mechanisms, e.g. drawing on community opinion and/or the view of the Question author.
     */
    protected Answer|ItemList $acceptedAnswer;
    public function getAcceptedAnswer():Answer|ItemList|null
    {
        return $this->acceptedAnswer??null;
    }
    public function setAcceptedAnswer(Answer|ItemList $acceptedAnswer):void
    {
        $this->acceptedAnswer = $acceptedAnswer;
    }
}
inc/managers/SEO/render/Traits/_Properties/additionalPropertyTrait.php
@@ -23,7 +23,20 @@
    public function setAdditionalProperty(PropertyValue|array $additionalProperty):void
    {
        if (is_array($additionalProperty)) {
            if (!is_numeric(array_key_first($additionalProperty))) {
                $additionalProperty = [$additionalProperty];
            }
            $additionalProperty = array_map(function($property) {
                if (!array_key_exists('type', $property)) {
                    $property['type'] = 'JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\PropertyValue';
                }
                return $property;
            }, $additionalProperty);
            $additionalProperty = $this->classArray('additionalProperty', $additionalProperty, 'JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\PropertyValue');
            $additionalProperty = array_filter($additionalProperty, function ($property) {
                return property_exists($property, 'value') && !empty($property->value);
            });
        }
        $this->additionalProperty = $additionalProperty;
    }
inc/managers/SEO/render/Traits/_Properties/addressTrait.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\base\SchemaHelper;
use JVBase\managers\SEO\render\Thing\Intangible\ContactPoint\PostalAddress;
if (!defined('ABSPATH')) {
@@ -19,7 +20,10 @@
    public function setAddress(PostalAddress|array|string $address):void
    {
        if (is_array($address)){
            $address = PostalAddress::fromArray($address);
            if (!array_key_exists('type', $address)) {
                $address['type'] = 'JVBase\managers\SEO\render\Thing\Intangible\ContactPoint\PostalAddress';
            }
            $address = SchemaHelper::classFromConfig($address);
        }
        $this->address = $address;
    }
@@ -47,7 +51,7 @@
                'addressLocality'   => [
                    'type'  => 'text',
                    'label' => 'Address Locality',
                    'hint'  => 'The locality in which the street address is, and which is in the region. For example, "Park Allen".'
                    'hint'  => 'The locality in which the street address is, and which is in the region. For example, "Edmonton".'
                ],
                'addressRegion' => [
                    'type'  => 'text',
inc/managers/SEO/render/Traits/_Properties/aggregateRatingTrait.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\base\SchemaHelper;
use JVBase\managers\SEO\render\Thing\Intangible\Rating\AggregateRating;
if (!defined('ABSPATH')) {
@@ -16,10 +17,14 @@
    {
        return $this->aggregateRating??null;
    }
    public function setAggregateRating(array|AggregateRating $aggregateRating):void
    {
        if (is_array($aggregateRating)){
            $aggregateRating =AggregateRating::fromArray($aggregateRating);
            if (!array_key_exists('type', $aggregateRating)) {
                $aggregateRating['type'] = 'JVBase\managers\SEO\render\Thing\Intangible\Rating\AggregateRating';
            }
            $aggregateRating = SchemaHelper::classFromConfig($aggregateRating);
        }
        $this->aggregateRating = $aggregateRating;
    }
inc/managers/SEO/render/Traits/_Properties/answerCountTrait.php
New file
@@ -0,0 +1,21 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
if (!defined('ABSPATH')) {
    exit;
}
trait answerCountTrait {
    /**
     * @var int The number of answers this question, answer or comment has received from the community.
     */
    protected int $answerCount;
    public function getAnswerCount():?int
    {
        return $this->answerCount??null;
    }
    public function setAnswerCount(int $answerCount):void
    {
        $this->answerCount = $answerCount;
    }
}
inc/managers/SEO/render/Traits/_Properties/answerExplanationTrait.php
New file
@@ -0,0 +1,24 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
use JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Comment;
if (!defined('ABSPATH')) {
    exit;
}
trait answerExplanationTrait {
    /**
     * @var Comment A step-by-step or full explanation about Answer. Can outline how this Answer was achieved or contain more broad clarification or statement about it.
     * TODO Can also be "WebContent", not included yet
     */
    protected Comment $answerExplanation;
    public function getAnswerExplanation():?Comment
    {
        return $this->answerExplanation??null;
    }
    public function setAnswerExplanation(Comment $answerExplanation):void
    {
        $this->answerExplanation = $answerExplanation;
    }
}
inc/managers/SEO/render/Traits/_Properties/closesTrait.php
@@ -16,8 +16,11 @@
    {
        return $this->closes??null;
    }
    public function setCloses(Time $closes):void
    public function setCloses(Time|string $closes):void
    {
        if (is_string($closes)) {
            $closes = new Time($closes);
        }
        $this->closes = $closes;
    }
}
inc/managers/SEO/render/Traits/_Properties/dayOfWeekTrait.php
@@ -2,22 +2,27 @@
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Intangible\Enumeration\DayOfWeek;
use JVBase\managers\SEO\render\Traits\_Helpers\enumerationHelper;
if (!defined('ABSPATH')) {
    exit;
}
trait dayOfWeekTrait {
    use enumerationHelper;
    /**
     * @var DayOfWeek The day of the week from which these opening hours are valid
     * @var DayOfWeek|array The day of the week from which these opening hours are valid
     */
    protected DayOfWeek $dayOfWeek;
    protected DayOfWeek|array $dayOfWeek;
    public function getDayOfWeek():?DayOfWeek
    public function getDayOfWeek():DayOfWeek|array|null
    {
        return $this->dayOfWeek??null;
    }
    public function setDayOfWeek(DayOfWeek $dayOfWeek):void
    public function setDayOfWeek(DayOfWeek|array $dayOfWeek):void
    {
        if (is_array($dayOfWeek)) {
        }
        $this->dayOfWeek = $dayOfWeek;
    }
}
inc/managers/SEO/render/Traits/_Properties/downvoteCountTrait.php
New file
@@ -0,0 +1,21 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
if (!defined('ABSPATH')) {
    exit;
}
trait downvoteCountTrait {
    /**
     * @var int The number of downvotes this question, answer or comment has received from the community.
     */
    protected int $downvoteCount;
    public function getDownvoteCount():?int
    {
        return $this->downvoteCount??null;
    }
    public function setDownvoteCount(int $downvoteCount):void
    {
        $this->downvoteCount = $downvoteCount;
    }
}
inc/managers/SEO/render/Traits/_Properties/founderTrait.php
@@ -1,6 +1,5 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Organization\Organization;
use JVBase\managers\SEO\render\Thing\Person\Person;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
inc/managers/SEO/render/Traits/_Properties/geoTrait.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\base\SchemaHelper;
use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\GeoCoordinates;
use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\GeoShape;
@@ -17,8 +18,15 @@
    {
        return $this->geo??null;
    }
    public function setGeo(GeoCoordinates|GeoShape $geo):void
    public function setGeo(GeoCoordinates|GeoShape|array $geo):void
    {
        if (is_array($geo)) {
            if(!array_key_exists('type', $geo)) {
                $geo['type'] = 'JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\GeoCoordinates';
            }
            $geo = SchemaHelper::classFromConfig($geo);
        }
        $this->geo = $geo;
    }
    public function getGeoFieldConfig():array
inc/managers/SEO/render/Traits/_Properties/hasOfferCatalogTrait.php
@@ -1,7 +1,8 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Intangible\OfferCatalog;
use JVBase\base\SchemaHelper;
use JVBase\inc\managers\SEO\render\Thing\Intangible\ItemList\OfferCatalog;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
@@ -17,8 +18,26 @@
    {
        return $this->hasOfferCatalog??null;
    }
    public function setHasOfferCatalog(OfferCatalog $hasOfferCatalog):void
    public function setHasOfferCatalog(OfferCatalog|array $hasOfferCatalog):void
    {
        if (is_array($hasOfferCatalog)){
            if (array_key_exists('generate', $hasOfferCatalog) && $hasOfferCatalog['generate']) {
                $this->generateOfferCatalog($hasOfferCatalog);
            } else {
                if (!array_key_exists('type', $hasOfferCatalog)) {
                    $hasOfferCatalog['type'] = 'JVBase\inc\managers\SEO\render\Thing\Intangible\ItemList\OfferCatalog';
                }
                $items = [];
                if (array_key_exists('items', $hasOfferCatalog)) {
                    $items = $hasOfferCatalog['items'];
                    unset($hasOfferCatalog['items']);
                }
                $hasOfferCatalog = SchemaHelper::classFromConfig($hasOfferCatalog);
                if (!empty($items)) {
                    $hasOfferCatalog->setItemListElement($items);
                }
            }
        }
        $this->hasOfferCatalog = $hasOfferCatalog;
    }
@@ -31,7 +50,8 @@
            'fields'    => [
                'generate'  => [
                    'type'  => 'true_false',
                    'label' => 'Generate from Content type?'
                    'label' => 'Generate from Content type?',
                    'default'   => false,
                ],
                'content_type'  => [
                    'type'  => 'checkbox',
@@ -67,6 +87,10 @@
                        'description'   => [
                            'type'  => 'textarea',
                            'label' => 'Description'
                        ],
                        'price' => [
                            'type'  => 'number',
                            'label' => 'Price'
                        ]
                    ]
                ]
inc/managers/SEO/render/Traits/_Properties/heightTrait.php
@@ -9,15 +9,15 @@
}
trait heightTrait {
    /**
     * @var Distance|QuantitativeValue The height of the item.
     * @var Distance|QuantitativeValue|string The height of the item.
     */
    protected Distance|QuantitativeValue $height;
    protected Distance|QuantitativeValue|string $height;
    public function getHeight():Distance|QuantitativeValue|null
    public function getHeight():Distance|QuantitativeValue|string|null
    {
        return $this->height??null;
    }
    public function setHeight(Distance|QuantitativeValue $height):void
    public function setHeight(Distance|QuantitativeValue|string $height):void
    {
        $this->height = $height;
    }
inc/managers/SEO/render/Traits/_Properties/imageTrait.php
@@ -2,22 +2,27 @@
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\CreativeWork\MediaObject\ImageObject;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
if (!defined('ABSPATH')) {
    exit;
}
trait imageTrait {
    use arrayHelper;
    /**
     * @var string|ImageObject Can be a URL, or a fully described ImageObject
     * @var string|ImageObject|array Can be a URL, or a fully described ImageObject
     */
    protected string|ImageObject $image;
    protected string|ImageObject|array $image;
    public function getImage():?string
    {
        return $this->image??null;
    }
    public function setImage(string|ImageObject $image):void
    public function setImage(string|ImageObject|array $image):void
    {
        if (is_array($image)) {
            $image = $this->classArray('image', $image, 'JVBase\managers\SEO\render\Thing\CreativeWork\MediaObject\ImageObject');
        }
        $this->image = $image;
    }
inc/managers/SEO/render/Traits/_Properties/interactionTypeTrait.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Action;
use JVBase\inc\managers\SEO\render\Thing\Action\Action;
if (!defined('ABSPATH')) {
    exit;
inc/managers/SEO/render/Traits/_Properties/logoTrait.php
@@ -18,6 +18,9 @@
    }
    public function setLogo(string|ImageObject $logo):void
    {
        if (is_string($logo) && is_numeric($logo)) {
            $logo = self::createImageFromID($logo);
        }
        $this->logo = $logo;
    }
inc/managers/SEO/render/Traits/_Properties/mainEntityTrait.php
@@ -2,23 +2,28 @@
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Thing;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
if (!defined('ABSPATH')) {
    exit;
}
trait mainEntityTrait {
    use arrayHelper;
    /**
     * @var Thing Indicates the primary entity described in some page or other CreativeWork.
     * @var Thing|array Indicates the primary entity described in some page or other CreativeWork.
     * Inverse property: mainEntityOfPage
     */
    protected Thing $mainEntity;
    protected Thing|array $mainEntity;
    public function getMainEntity():?Thing
    public function getMainEntity():Thing|array|null
    {
        return $this->mainEntity??null;
    }
    public function setMainEntity(Thing $mainEntity):void
    public function setMainEntity(Thing|array $mainEntity):void
    {
        if (is_array($mainEntity)) {
            $mainEntity = $this->classArray('mainEntity', $mainEntity, 'JVBase\managers\SEO\render\Thing\Thing');
        }
        $this->mainEntity = $mainEntity;
    }
}
inc/managers/SEO/render/Traits/_Properties/openingHoursSpecificationTrait.php
@@ -1,23 +1,43 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\base\SchemaHelper;
use JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\OpeningHoursSpecification;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
if (!defined('ABSPATH')) {
    exit;
}
trait openingHoursSpecificationTrait {
    use arrayHelper;
    /**
     * @var OpeningHoursSpecification The opening hours of a certain place.
     * @var OpeningHoursSpecification|array The opening hours of a certain place.
     */
    protected OpeningHoursSpecification $openingHoursSpecification;
    protected OpeningHoursSpecification|array $openingHoursSpecification;
    public function getOpeningHoursSpecification():?OpeningHoursSpecification
    public function getOpeningHoursSpecification():OpeningHoursSpecification|array|null
    {
        return $this->openingHoursSpecification??null;
    }
    public function setOpeningHoursSpecification(OpeningHoursSpecification $openingHoursSpecification):void
    public function setOpeningHoursSpecification(OpeningHoursSpecification|array $openingHoursSpecification):void
    {
        if (is_array($openingHoursSpecification)) {
            if (array_key_exists('dayOfWeek', $openingHoursSpecification)) {
                if (!array_key_exists('type', $openingHoursSpecification)) {
                    $openingHoursSpecification['type'] = 'JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\OpeningHoursSpecification';
                }
                $openingHoursSpecification = SchemaHelper::classFromConfig($openingHoursSpecification);
            } else {
                $out = [];
                foreach ($openingHoursSpecification as $hours) {
                    if (!array_key_exists('type', $hours)){
                        $hours['type'] = 'JVBase\managers\SEO\render\Thing\Intangible\StructuredValue\OpeningHoursSpecification';
                    }
                    $out[] = SchemaHelper::classFromConfig($hours);
                }
                $openingHoursSpecification = $out;
            }
        }
        $this->openingHoursSpecification = $openingHoursSpecification;
    }
inc/managers/SEO/render/Traits/_Properties/opensTrait.php
@@ -16,8 +16,11 @@
    {
        return $this->opens??null;
    }
    public function setOpens(Time $opens):void
    public function setOpens(Time|string $opens):void
    {
        if (is_string($opens)) {
            $opens = new Time($opens);
        }
        $this->opens = $opens;
    }
}
inc/managers/SEO/render/Traits/_Properties/parentItemTrait.php
New file
@@ -0,0 +1,32 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
use JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Comment;
use JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
if (!defined('ABSPATH')) {
    exit;
}
trait parentItemTrait {
    use arrayHelper;
    /**
     * @var Comment|CreativeWork|array The parent of a question, answer or item in general. Typically used for Q/A discussion threads e.g. a chain of comments with the first comment being an Article or other CreativeWork. See also comment which points from something to a comment about it.
     */
    protected Comment|CreativeWork|array $parentItem;
    public function getParent():Comment|CreativeWork|array|null
    {
        return $this->parentItem??null;
    }
    public function setParent(Comment|CreativeWork|array $parentItem):void
    {
        if (is_array($parentItem)) {
            $parentItem = $this->mixedArray('parentItem', $parentItem, [
                'JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork',
                'JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Comment'
            ]);
        }
        $this->parentItem = $parentItem;
    }
}
inc/managers/SEO/render/Traits/_Properties/potentialActionTrait.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Thing\Action;
use JVBase\inc\managers\SEO\render\Thing\Action\Action;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
if (!defined('ABSPATH')) {
@@ -21,7 +21,17 @@
    public function setPotentialAction(Action|array $potentialAction):void
    {
        if (is_array($potentialAction)) {
            $potentialAction = $this->classArray('potentialAction', $potentialAction, 'JVBase\managers\SEO\render\Thing\Action');
            $potentialAction = $this->classArray('potentialAction', $potentialAction, 'JVBase\inc\managers\SEO\render\Thing\Action\Action');
            $potentialAction = array_map(
                function ($item) {
                    $target = $item->getTarget();
                    $url = $target->getUrlTemplate();
                    $target->setId($url.'#entrypoint');
                    return $item;
                },
                $potentialAction
            );
            if (empty($potentialAction)) {
                return;
            }
inc/managers/SEO/render/Traits/_Properties/scheduledTimeTrait.php
New file
@@ -0,0 +1,26 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\DataType\Time;
if (!defined('ABSPATH')) {
    exit;
}
trait scheduledTimeTrait {
    /**
     * @var Time The opening hour of the place or service on the given day(s) of the week
     */
    protected Time $scheduledTime;
    public function getScheduledTime():?Time
    {
        return $this->scheduledTime??null;
    }
    public function setScheduledTime(Time|string $scheduledTime):void
    {
        if (is_string($scheduledTime)) {
            $scheduledTime = new Time($scheduledTime);
        }
        $this->scheduledTime = $scheduledTime;
    }
}
inc/managers/SEO/render/Traits/_Properties/sharedContentTrait.php
New file
@@ -0,0 +1,27 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
use JVBase\managers\SEO\render\Traits\_Helpers\arrayHelper;
use JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork;
if (!defined('ABSPATH')) {
    exit;
}
trait sharedContentTrait {
    use arrayHelper;
    /**
     * @var CreativeWork|array A CreativeWork such as an image, video, or audio clip shared as part of this posting.
     */
    protected CreativeWork|array $shared_content;
    public function getSharedContent():CreativeWork|array|null
    {
        return $this->shared_content??null;
    }
    public function setSharedContent(CreativeWork|array $sharedContent):void
    {
        if (is_array($sharedContent)) {
            $sharedContent = $this->classArray('sharedContent', $sharedContent, 'JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork');
        }
        $this->shared_content = $sharedContent;
    }
}
inc/managers/SEO/render/Traits/_Properties/suggestedAnswerTrait.php
New file
@@ -0,0 +1,24 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
use JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Answer;
use JVBase\managers\SEO\render\Thing\Intangible\ItemList\ItemList;
if (!defined('ABSPATH')) {
    exit;
}
trait suggestedAnswerTrait {
    /**
     * @var Answer|ItemList The answer(s) that has been suggested as best, typically on a Question/Answer site. Sites vary in their selection mechanisms, e.g. drawing on community opinion and/or the view of the Question author.
     */
    protected string $suggestedAnswer;
    public function getSuggestedAnswer():?string
    {
        return $this->suggestedAnswer??null;
    }
    public function setSuggestedAnswer(string $suggestedAnswer):void
    {
        $this->suggestedAnswer = $suggestedAnswer;
    }
}
inc/managers/SEO/render/Traits/_Properties/targetTrait.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers\SEO\render\Traits\_Properties;
use JVBase\base\SchemaHelper;
use JVBase\managers\SEO\render\Thing\Intangible\EntryPoint;
if (!defined('ABSPATH')) {
@@ -8,16 +9,26 @@
}
trait targetTrait {
    /**
     * @var EntryPoint|string Indicates a target EntryPoint, or url, for an Action.
     * @var EntryPoint Indicates a target EntryPoint, or url, for an Action.
     */
    protected EntryPoint|string $description;
    protected EntryPoint $target;
    public function getDescription():EntryPoint|string|null
    public function getTarget():EntryPoint|null
    {
        return $this->description??null;
        return $this->target??null;
    }
    public function setDescription(EntryPoint|string $description):void
    public function setTarget(EntryPoint|string $target):void
    {
        $this->description = $description;
        if (is_string($target)) {
            if (str_starts_with($target, '/')) {
                $target = home_url().$target;
            }
            $temp = [
                'type'  => 'JVBase\managers\SEO\render\Thing\Intangible\EntryPoint',
                'urlTemplate'   => $target
            ];
            $target = SchemaHelper::classFromConfig($temp);
        }
        $this->target = $target;
    }
}
inc/managers/SEO/render/Traits/_Properties/upvoteCountTrait.php
New file
@@ -0,0 +1,21 @@
<?php
namespace JVBase\inc\managers\SEO\render\Traits\_Properties;
if (!defined('ABSPATH')) {
    exit;
}
trait upvoteCountTrait {
    /**
     * @var int The number of upvotes this question, answer or comment has received from the community.
     */
    protected int $upvoteCount;
    public function getUpvoteCount():?int
    {
        return $this->upvoteCount??null;
    }
    public function setUpvoteCount(int $upvoteCount):void
    {
        $this->upvoteCount = $upvoteCount;
    }
}
inc/managers/SEO/render/Traits/_Properties/widthTrait.php
@@ -9,15 +9,15 @@
}
trait widthTrait {
    /**
     * @var Distance|QuantitativeValue The width of the item.
     * @var Distance|QuantitativeValue|string The width of the item.
     */
    protected Distance|QuantitativeValue $width;
    protected Distance|QuantitativeValue|string $width;
    public function getWidth():Distance|QuantitativeValue|null
    public function getWidth():Distance|QuantitativeValue|string|null
    {
        return $this->width??null;
    }
    public function setWidth(Distance|QuantitativeValue $width):void
    public function setWidth(Distance|QuantitativeValue|string $width):void
    {
        $this->width = $width;
    }
inc/managers/TaxonomyRelationships.php
@@ -1,9 +1,10 @@
<?php
namespace JVBase\managers;
use JVBase\managers\Cache;
use Exception;
use WP_Error;
use WP_Post;
use WP_Query;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
@@ -11,15 +12,15 @@
class TaxonomyRelationships
{
    private string $table_name;
    protected object $cache;
    protected Cache $cache;
    protected CustomTable $relationships;
    public function __construct()
    {
        global $wpdb;
        $this->table_name = $wpdb->prefix . BASE.'taxonomy_relationships';
        $this->defineTables();
        $this->cache = Cache::for('term_relationship', WEEK_IN_SECONDS);
        $this->cache->connect('terms');
        // Ensure the table exists
//        $this->create_table_if_not_exists();
@@ -28,13 +29,41 @@
        add_action('init', [$this, 'init']);
    }
    /**
     * @return string
     */
    public function getTableName():string
    {
        return $this->table_name;
    }
    protected function defineTables():void
    {
        $relationships = CustomTable::for('taxonomy_relationships');
        $relationships->setColumns([
            'id'                => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'term_id'           => "{$relationships->getTermIDType()} NOT NULL",
            'related_term_id'   => "{$relationships->getTermIDType()} NOT NULL",
            'taxonomy'          => 'varchar(32) NOT NULL',
            'related_taxonomy'  => 'varchar(32) NOT NULL',
            'post_count'        => 'int(11) NOT NULL DEFAULT 0',
            'is_direct'         => 'tinyint(1) NOT NULL DEFAULT 1',
            'is_hierarchical'   => 'tinyint(1) NOT NULL DEFAULT 0',
            'last_updated'      => 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
        ]);
        $relationships->setKeys([
            ['key'=>'PRIMARY', 'value' => '(`id`)'],
            ['key'=>'UNIQUE', 'value' => '`relation` (`term_id`, `related_term_id`, `taxonomy`, `related_taxonomy`)'],
            '`term_id` (`term_id`)',
            '`related_term_id` (`related_term_id`)',
            '`taxonomy` (`taxonomy`)',
            '`related_taxonomy` (`related_taxonomy`)'
        ]);
        $base = BASE;
        $relationships->setConstraints([
            "CONSTRAINT {$base}tax_rel_term_id FOREIGN KEY (`term_id`)
            REFERENCES `{$relationships->getTermTable()}` (`term_id`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}tax_rel_related_id` FOREIGN KEY (`related_term_id`)
            REFERENCES `{$relationships->getTermTable()}` (`term_id`) ON DELETE CASCADE"
        ]);
        $relationships->defineTable();
        $this->relationships = $relationships;
    }
    /**
     * Hook into term and post saves
@@ -59,6 +88,10 @@
     */
    public function updatePostRelationships(int $post_id, WP_Post $post):void
    {
        // Skip autosaves and revisions
        if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
            return;
        }
        $post_type = $post->post_type;
        if (in_array($post_type, jvbIgnoredPostTypes())) {
            return;
@@ -119,8 +152,6 @@
     */
    public function updateRelationship(int $term_id, int $related_term_id, string $taxonomy, string $related_taxonomy):bool
    {
        global $wpdb;
        // Make sure both terms exist
        $term = get_term($term_id, $taxonomy);
        $related_term = get_term($related_term_id, $related_taxonomy);
@@ -130,61 +161,27 @@
            return false;
        }
        // Ensure term_id and related_term_id are integers
        $term_id = (int)$term_id;
        $related_term_id = (int)$related_term_id;
        // Check if term is parent of related term
        $is_hierarchical = 0;
        if ($taxonomy === $related_taxonomy) {
            $ancestors = get_ancestors($related_term_id, $taxonomy, 'taxonomy');
            if (in_array($term_id, $ancestors)) {
                $is_hierarchical = 1;
            }
        }
        // Calculate number of shared posts
        $shared_posts_count = $this->countSharedPosts($term_id, $related_term_id, $taxonomy, $related_taxonomy);
        $updated = $this->relationships->findOrCreate([
            'term_id'           => $term_id,
            'related_term_id'   => $related_term_id,
            'taxonomy'          => $taxonomy,
            'related_taxonomy'  => $related_taxonomy
        ],[
            'is_hierarchical'   => $is_hierarchical,
            'post_count'    => $shared_posts_count
        ]);
        // Check if relationship exists
        $existing = $wpdb->get_row($wpdb->prepare(
            "SELECT id, post_count FROM {$this->table_name}
        WHERE term_id = %d AND related_term_id = %d
        AND taxonomy = %s AND related_taxonomy = %s",
            $term_id,
            $related_term_id,
            $taxonomy,
            $related_taxonomy
        ));
        // Calculate number of shared posts
        $shared_posts_count = $this->countSharedPosts($term_id, $related_term_id, $taxonomy, $related_taxonomy);
        // Check if term is parent of related term
        $is_hierarchical = 0;
        if ($taxonomy === $related_taxonomy) {
            $ancestors = get_ancestors($related_term_id, $taxonomy, 'taxonomy');
            if (in_array($term_id, $ancestors)) {
                $is_hierarchical = 1;
            }
        }
        if ($existing) {
            // Update existing relationship
            $wpdb->update(
                $this->table_name,
                [
                    'post_count' => $shared_posts_count,
                    'is_hierarchical' => $is_hierarchical
                ],
                [
                    'id' => $existing->id
                ]
            );
        } else {
            // Insert new relationship
            $wpdb->insert(
                $this->table_name,
                [
                    'term_id' => $term_id,
                    'related_term_id' => $related_term_id,
                    'taxonomy' => $taxonomy,
                    'related_taxonomy' => $related_taxonomy,
                    'post_count' => $shared_posts_count,
                    'is_hierarchical' => $is_hierarchical
                ]
            );
        }
        return true;
        return (bool)$updated;
    }
    /**
@@ -197,23 +194,35 @@
     */
    private function countSharedPosts(int $term_id, int $related_term_id, string $taxonomy, string $related_taxonomy):int
    {
        global $wpdb;
        $term_posts = $wpdb->get_col($wpdb->prepare(
            "SELECT object_id FROM {$wpdb->term_relationships} tr
        $term_posts = $this->cache->remember(
            $this->cache->generateKey(['term' => $term_id, 'taxonomy' => $taxonomy]),
            function () use ($term_id, $taxonomy) {
                global $wpdb;
                return $wpdb->get_col($wpdb->prepare(
                    "SELECT object_id FROM {$wpdb->term_relationships} tr
            JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
            WHERE tt.term_id = %d AND tt.taxonomy = %s",
            $term_id,
            $taxonomy
        ));
                    $term_id,
                    $taxonomy
                ));
            }
        );
        $related_term_posts = $wpdb->get_col($wpdb->prepare(
            "SELECT object_id FROM {$wpdb->term_relationships} tr
        $related_term_posts = $this->cache->remember(
            $this->cache->generateKey(['term' => $related_term_id, 'taxonomy' => $related_taxonomy]),
            function () use($related_term_id, $related_taxonomy) {
                global $wpdb;
                return $wpdb->get_col($wpdb->prepare(
                    "SELECT object_id FROM {$wpdb->term_relationships} tr
            JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
            WHERE tt.term_id = %d AND tt.taxonomy = %s",
            $related_term_id,
            $related_taxonomy
        ));
                    $related_term_id,
                    $related_taxonomy
                ));
            });
        return count(array_intersect($term_posts, $related_term_posts));
    }
@@ -230,48 +239,14 @@
    public function getRelatedTerms(int $term_id, string $desired_taxonomy, bool $include_hierarchical = true):array
    {
        $key = sprintf(
            '%d_to_%s',
            $term_id,
            $desired_taxonomy
        );
        if ($include_hierarchical) {
            $key.='_hierarchy';
        }
        $cache = $this->cache->get($key);
        if ($cache) {
            return $cache;
        }
        global $wpdb;
        $term = get_term($term_id);
        if (is_wp_error($term) || empty($term)) {
            return [];
        }
        $query = $wpdb->prepare(
            "SELECT related_term_id
            FROM {$this->table_name}
            WHERE term_id = %d
            AND related_taxonomy = %s",
            $term_id,
            $desired_taxonomy
        );
        // Optionally include hierarchical relationships
        if (!$include_hierarchical) {
            $query .= " AND is_hierarchical = 0";
        }
        // Order by post count for popularity
        $query .= " ORDER BY post_count DESC";
        $related_term_ids = $wpdb->get_col($query);
        $this->cache->set($key, $related_term_ids);
        return $related_term_ids;
        $where = [
            'term_id'   => $term_id,
            'related_taxonomy' => $desired_taxonomy
        ];
        if (!$include_hierarchical) {
            $where['is_hierarchical'] = 0;
        }
        return $this->relationships->pluck('related_term_id', $where, 'post_count', 'DESC');
    }
    // Get all related terms based on multiple source terms (for "any" match)
@@ -284,50 +259,9 @@
     */
    public function getAnyRelatedTerms(array $term_ids, string $taxonomy):array
    {
        if (empty($term_ids)) {
            return [];
        }
        $term_ids = array_filter(array_map('absint', $term_ids));
        return array_unique($this->relationships->pluck('related_term_id', ['term_id' => ['IN' => $term_ids], 'related_taxonomy' => $taxonomy], 'post_count', 'DESC'));
        $related_term_ids = [];
        foreach ($term_ids as $term_id) {
            $related = $this->getRelatedTerms($term_id, $taxonomy);
            $related_term_ids = array_merge($related_term_ids, $related);
        }
        return array_unique($related_term_ids);
    }
    // Get related terms that are common to all source terms (for "all" match)
    /**
     * @param array $term_ids
     * @param string $taxonomy
     *
     * @return array
     */
    public function getAllRelatedTerms(array $term_ids, string $taxonomy):array
    {
        if (empty($term_ids)) {
            return [];
        }
        $related_sets = [];
        foreach ($term_ids as $term_id) {
            $related = $this->getRelatedTerms($term_id, $taxonomy);
            if (!empty($related)) {
                $related_sets[] = $related;
            }
        }
        if (count($related_sets) === 1) {
            return $related_sets[0];
        } elseif (count($related_sets) > 1) {
            // Find intersection of all sets
            $result = array_intersect(...$related_sets);
            return array_values($result);
        }
        return [];
    }
    // Rebuild all relationships (useful for initial setup)
@@ -341,31 +275,34 @@
        global $wpdb;
        // Clear existing relationships
        $wpdb->query("TRUNCATE TABLE {$this->table_name}");
        $total_posts = $wpdb->get_var("SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_status = 'publish'");
        $wpdb->query("TRUNCATE TABLE {$this->relationships->getFullTableName()}");
        // Calculate number of batches needed (50 posts per batch)
        $batch_size = 50;
        $total_batches = ceil($total_posts / $batch_size);
        $posts = new WP_Query([
            'post_status'   => 'publish',
            'posts_per_page'=> -1,
            'fields'        => 'ids'
        ]);
        if (!$posts->have_posts()) {
            wp_reset_postdata();
            return true;
        }
        $ids = $posts->posts;
        wp_reset_postdata();
        // Queue the operation
        $queue = JVB()->queue();
        $operation_id = 'taxonomy_rebuild_' . uniqid();
        $queue->queueOperation(
            'taxonomy_relationships',
            1,
            [
                'action' => 'rebuild_all',
                'offset' => 0,
                'limit' => $batch_size,
                'total_posts' => $total_posts,
                'total_batches' => $total_batches
                'posts' => $ids,
            ],
            [
                'operation_id' => $operation_id,
                'count' => $total_batches,
                'priority' => 'normal',
                'chunk_key'     => 'posts',
                'chunk_size'    => 50
            ]
        );
@@ -387,47 +324,20 @@
            return $result;
        }
        if (isset($data['action']) && $data['action'] === 'rebuild_all') {
            // Clear existing relationships
            global $wpdb;
            // Get batch of posts to process
            $offset = isset($data['offset']) ? $data['offset'] : 0;
            $limit = isset($data['limit']) ? $data['limit'] : 50; // Process in batches
            $posts = $wpdb->get_col($wpdb->prepare(
                "SELECT ID FROM {$wpdb->posts}
             WHERE post_status = 'publish'
             ORDER BY ID
             LIMIT %d OFFSET %d",
                $limit,
                $offset
            ));
            // Process each post
            foreach ($posts as $post_id) {
                $this->updatePostRelationships($post_id);
            }
            // Get total number of posts
            $total_posts = $wpdb->get_var("SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_status = 'publish'");
            // Return progress information
            return [
                'success' => true,
                'result'    => [
                    'processed' => count($posts),
                    'offset' => $offset,
                    'next_offset' => $offset + $limit,
                    'total' => $total_posts,
                    'completed' => ($offset + $limit >= $total_posts)
                ]
            ];
        }
        return [
            'success'   => false,
            'message'   => __('Hmmm.', 'jvb')
        ];
        try {
            foreach ($data['posts'] as $postID) {
                $post = get_post($postID);
                $this->updatePostRelationships($postID, $post);
            }
        } catch (Exception $e) {
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
        return [
            'success' => true
        ];
    }
    // Hook this to term deletion
@@ -439,13 +349,7 @@
     */
    public function deleteTermRelationships(int $term_id):void
    {
        global $wpdb;
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$this->table_name}
            WHERE term_id = %d OR related_term_id = %d",
            $term_id,
            $term_id
        ));
        $termIDs = $this->relationships->delete(['term_id' => $term_id]);
        $related = $this->relationships->delete(['related_term_id' => $term_id]);
    }
}
inc/managers/UserTermsManager.php
@@ -1,36 +1,65 @@
<?php
namespace JVBase\managers;
use JVBase\registrar\Registrar;
use WP_Post;
use WP_Error;
use Exception;
use WP_Query;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class UserTermsManager
{
    private string $table_name;
    private Cache $cache;
    private string $cacheGroup  = 'user_terms_';
    private int $ttl = DAY_IN_SECONDS; // 1 day default
    protected \wpdb $wpdb;
    protected CustomTable $index;
    public function __construct()
    {
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->table_name = $this->wpdb->prefix . BASE . 'user_term_index';
        $this->defineTables();
        $this->cache = Cache::for('term_ids')->connect('user');
        // Register hooks
        add_action('save_post', [$this, 'updatePostUserTerms'], 10, 3);
        add_action('before_delete_post', [$this, 'removePostUserTerms']);
        add_action('set_object_terms', [$this, 'handleTermAssignment'], 10, 6);
        // Add filter for bulk operation handling
        add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
    }
    public function defineTables():void
    {
        $userIndex = CustomTable::for('user_term_index');
        $userIndex->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'user_id'       => "{$userIndex->getUserIDType()} NOT NULL",
            'term_id'       => "{$userIndex->getTermIDType()} NOT NULL",
            'taxonomy'      => 'varchar(32) NOT NULL',
            'post_count'    => 'int(11) NOT NULL DEFAULT 1',
            'parent_id'     => "{$userIndex->getTermIDType()} NOT NULL DEFAULT 0",
            'last_used'     => 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
        ]);
        $userIndex->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => '`user_term` (`user_id`, `term_id`, `taxonomy`)'],
            '`user_taxonomy` (`user_id`, `taxonomy`)',
            '`taxonomy` (`taxonomy`)',
            '`user_id` (`user_id`)',
            '`term_id` (`term_id`)',
            '`parent_id` (`parent_id`)'
        ]);
        $base = BASE;
        $userIndex->setConstraints([
            "CONSTRAINT `{$base}user_term_user_fk` FOREIGN KEY (`user_id`)
            REFERENCES `{$userIndex->getUserTable()}` (`ID`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}user_term_term_fk` FOREIGN KEY (`term_id`)
            REFERENCES `{$userIndex->getTermTable()}` (`term_id`) ON DELETE CASCADE"
        ]);
        $userIndex->defineTable();
        $this->index = $userIndex;
    }
    /**
     * @param int $user_id
     * @param string|null $taxonomy
@@ -43,79 +72,8 @@
        $cache->flush();
    }
    // Update term usage when a post is saved
    /**
     * @param int $post_id
     * @param WP_Post $post
     * @param bool $update
     *
     * @return void
     */
    public function updatePostUserTerms(int $post_id, WP_Post $post, bool $update):void
    {
        // Skip autosaves and revisions
        if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
            return;
        }
        // SAFETY: Skip attachments and other non-content post types
        if (in_array($post->post_type, jvbIgnoredPostTypes())) {
            return;
        }
        // Skip non-custom post types
        $post_type = get_post_type($post);
        if (!str_starts_with($post_type, BASE)) {
            return;
        }
        $user_id = $post->post_author;
        // Get all taxonomies for this post type
        $taxonomies = get_object_taxonomies($post_type);
        foreach ($taxonomies as $taxonomy) {
            $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']);
            if (!is_wp_error($terms) && !empty($terms)) {
                // Add the direct terms
                foreach ($terms as $term_id) {
                    $this->updateUserTerm($user_id, $term_id, $taxonomy, false);
                    // Check if taxonomy is hierarchical and add parent terms
                    if (is_taxonomy_hierarchical($taxonomy)) {
                        $this->addParentTerms($user_id, $term_id, $taxonomy);
                    }
                }
            }
            $this->clearUserCache($user_id, $taxonomy);
        }
    }
    // Add all parent terms for a given term
    /**
     * @param int $user_id
     * @param int $term_id
     * @param string $taxonomy
     *
     * @return void
     */
    public function addParentTerms(int $user_id, int $term_id, string $taxonomy):void
    {
        // Get all ancestors (parent terms)
        $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy');
        if (!empty($ancestors)) {
            foreach ($ancestors as $ancestor_id) {
                $this->updateUserTerm($user_id, $ancestor_id, $taxonomy, true);
            }
        }
    }
    // Handle term assignment changes
    /**
     * Handle term assignment changes
     * @param int $object_id
     * @param array $terms
     * @param array $tt_ids
@@ -135,174 +93,28 @@
        }
        $user_id = $post->post_author;
        $is_hierarchical = is_taxonomy_hierarchical($taxonomy);
        // Get added terms
        $added_tt_ids = array_diff($tt_ids, $old_tt_ids);
        $added_terms = [];
        if (!empty($added_tt_ids)) {
            foreach ($added_tt_ids as $tt_id) {
                $term_id = $this->getTermIDFromTTID($tt_id);
                if ($term_id) {
                    $added_terms[] = $term_id;
                }
            }
            // Add or increment the new terms
            foreach ($added_terms as $term_id) {
                $this->updateUserTerm($user_id, $term_id, $taxonomy, false);
                // Add parent terms for hierarchical taxonomies
                if ($is_hierarchical) {
                    $this->addParentTerms($user_id, $term_id, $taxonomy);
                }
            }
        }
        // Handle removed terms
        $removed_tt_ids = array_diff($old_tt_ids, $tt_ids);
        $removed_terms = [];
        if (!empty($removed_tt_ids)) {
            foreach ($removed_tt_ids as $tt_id) {
                $term_id = $this->getTermIDFromTTID($tt_id);
                if ($term_id) {
                    $removed_terms[] = $term_id;
                }
            }
            // Decrement removed terms
            foreach ($removed_terms as $term_id) {
                $this->decreaseUserTerm($user_id, $term_id, $taxonomy, false);
                // Handle parent terms for hierarchical taxonomies
                if ($is_hierarchical) {
                    $this->handleParentTermRemoval($user_id, $term_id, $taxonomy);
                }
            }
        }
        $termIDs = array_unique(array_merge($tt_ids, $old_tt_ids));
       if (!empty($termIDs)) {
           foreach ($termIDs as $termID) {
               $term_id = $this->getTermIDFromTTID($termID);
               $this->updateUserTerm($user_id, $term_id, $taxonomy);
           }
       }
    }
    // Handles parent term removal logic
    /**
     * Updates the entry for the user's term relationship
     * @param int $user_id
     * @param int $term_id
     * @param string $taxonomy
     *
     * @return void
     */
    public function handleParentTermRemoval(int $user_id, int $term_id, string $taxonomy):void
    {
        // Get parent terms
        $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy');
        if (!empty($ancestors)) {
            foreach ($ancestors as $ancestor_id) {
                // Check if this parent is still used by other terms
                $still_needed = $this->isParentNeeded($user_id, $ancestor_id, $taxonomy);
                if (!$still_needed) {
                    $this->decreaseUserTerm($user_id, $ancestor_id, $taxonomy, true);
                }
            }
        }
    }
    // Check if a parent term is still needed by other terms
    /**
     * @param int $user_id
     * @param int $parent_term_id
     * @param string $taxonomy
     *
     * @return bool
     */
    private function isParentNeeded(int $user_id, int $parent_term_id, string $taxonomy):bool
    public function updateUserTerm(int $user_id, int $term_id, string $taxonomy):bool
    {
        // Get all direct terms the user has
        $direct_terms = $this->wpdb->get_col($this->wpdb->prepare(
            "SELECT term_id FROM {$this->table_name}
            WHERE user_id = %d
            AND taxonomy = %s
            AND is_parent = 0
            AND post_count > 0",
            $user_id,
            $taxonomy
        ));
        if (empty($direct_terms)) {
            return false;
        }
        // Check if any of these terms have the parent as an ancestor
        foreach ($direct_terms as $term_id) {
            if ($term_id == $parent_term_id) {
                continue; // Skip the term itself
            }
            $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy');
            if (in_array($parent_term_id, $ancestors)) {
                return true; // This parent is needed
            }
        }
        return false;
    }
    // Remove all term usage records for a post
    /**
     * @param int $post_id
     *
     * @return void
     */
    public function removePostUserTerms(int $post_id):void
    {
        $post = get_post($post_id);
        // Skip if not a valid post
        if (!$post || !str_starts_with($post->post_type, BASE)) {
            return;
        }
        $user_id = $post->post_author;
        $taxonomies = get_object_taxonomies($post->post_type);
        foreach ($taxonomies as $taxonomy) {
            $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']);
            $is_hierarchical = is_taxonomy_hierarchical($taxonomy);
            if (!is_wp_error($terms) && !empty($terms)) {
                foreach ($terms as $term_id) {
                    $this->decreaseUserTerm($user_id, $term_id, $taxonomy, false);
                    // Handle parent terms for hierarchical taxonomies
                    if ($is_hierarchical) {
                        $this->handleParentTermRemoval($user_id, $term_id, $taxonomy);
                    }
                }
            }
        }
    }
    // Add or increment a user-term relationship
    /**
     * @param int $user_id
     * @param int $term_id
     * @param string $taxonomy
     * @param bool $is_parent
     *
     * @return bool
     */
    public function updateUserTerm(int $user_id, int $term_id, string $taxonomy, bool $is_parent = false):bool
    {
        $taxonomy = jvbCheckBase($taxonomy);
        // Ensure the term exists
        $term = get_term($term_id, $taxonomy);
@@ -310,93 +122,53 @@
            return false;
        }
        // Insert or update the record
        $result = $this->wpdb->query($this->wpdb->prepare(
            "INSERT INTO {$this->table_name}
            (user_id, term_id, taxonomy, post_count, is_parent, last_used)
            VALUES (%d, %d, %s, 1, %d, NOW())
            ON DUPLICATE KEY UPDATE
            post_count = post_count + 1,
            is_parent = IF(%d = 1, 1, is_parent),
            last_used = NOW()",
            $user_id,
            $term_id,
            $taxonomy,
            $is_parent ? 1 : 0,
            $is_parent ? 1 : 0
        ));
        $posts = new WP_Query([
            'post_author'   => $user_id,
            'tax_query'     => [
                [
                    'taxonomy'  => $taxonomy,
                    'terms'     => $term_id,
                ]
            ],
            'post_status'   => 'publish',
            'posts_per_page'=> -1,
            'fields'        => 'ids'
        ]);
        $userPosts = count($posts->posts);
        // Clear the cache for this user and taxonomy
        $this->clearUserCache($user_id, $taxonomy);
        $result = $this->index->findOrCreate([
            'user_id'   => $user_id,
            'term_id'   => $term_id,
            'taxonomy'  => $taxonomy
        ],[
            'parent_id' => $term->parent,
            'post_count'=> $userPosts
        ]);
        return ($result !== false);
        if ($term->parent > 0) {
            $this->updateUserTerm($user_id, $term->parent, $taxonomy);
        }
        return (bool) $result;
    }
    // Decrement a user-term relationship
    /**
     * @param int $user_id
     * @param int $term_id
     * @param string $taxonomy
     * @param bool $is_parent
     *
     * @return bool
     */
    public function decreaseUserTerm(int $user_id, int $term_id, string $taxonomy, bool $is_parent = false):bool
    {
        // Update the record, decrementing the counter
        $this->wpdb->query($this->wpdb->prepare(
            "UPDATE {$this->table_name}
            SET post_count = GREATEST(post_count - 1, 0),
                last_used = NOW()
            WHERE user_id = %d
            AND term_id = %d
            AND taxonomy = %s",
            $user_id,
            $term_id,
            $taxonomy
        ));
        // Clean up zero-count records
        $this->wpdb->query($this->wpdb->prepare(
            "DELETE FROM {$this->table_name}
            WHERE user_id = %d
            AND term_id = %d
            AND taxonomy = %s
            AND post_count = 0",
            $user_id,
            $term_id,
            $taxonomy
        ));
        // Clear the cache for this user and taxonomy
        $this->clearUserCache($user_id, $taxonomy);
        return true;
    }
    // Helper function to get term_id from term_taxonomy_id
    /**
     * Helper function to get term_id from term_taxonomy_id
     * @param int $tt_id
     *
     * @return int
     */
    private function getTermIDFromTTID(int $tt_id):int
    {
        return $this->wpdb->get_var($this->wpdb->prepare(
            "SELECT term_id FROM {$this->wpdb->term_taxonomy} WHERE term_taxonomy_id = %d",
            $tt_id
        ));
        global $wpdb;
        return $wpdb->get_var($wpdb->prepare(
            "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE term_taxonomy_id = %d",
            $tt_id
        ));
    }
    // Rebuild the user terms index for all users
    /**
     * Rebuild the user terms index for all users
     * @return array
     */
    public function rebuildAllUserIndex():array
@@ -404,16 +176,19 @@
        // Clear existing index
        $this->wpdb->query("TRUNCATE TABLE {$this->table_name}");
        global $wpdb;
        $wpdb->query("TRUNCATE TABLE {$this->index->getFullTableName()}");
        // Get all users with posts
        $users = $this->wpdb->get_col($this->wpdb->prepare(
            "SELECT DISTINCT post_author
            FROM {$this->wpdb->posts}
            WHERE post_status = 'publish'
            AND post_type LIKE %s",
            $this->wpdb->esc_like(BASE) . '%'
        ));
        $users = get_users([
            'has_published_posts'   => [array_map('jvbCheckBase', Registrar::getRegistered('post'))],
            'fields' => 'ID'
        ]);
        if (empty($users)) {
            return [
                'success'   => true,
                'message'   => 'No users found to update'
            ];
        }
        JVB()->queue()->queueOperation(
            'rebuild_user_term_index',
@@ -422,7 +197,6 @@
                'users' => $users
            ],
            [
                'count'   => count($users),
                'chunk_key' => 'users',
                'chunk_size' => 5,
                'operation_id'      => 'rebuild_user_terms_' . date('Y_m_d')
@@ -435,15 +209,22 @@
        ];
    }
    // Rebuild the index for a specific user
    /**
     * Rebuild the index for a specific user
     * @param int $user_id
     *
     * @return array
     */
    public function rebuildUserIndex(int $user_id):array
    {
        $user = get_userdata($user_id);
        if (!$user) {
            return  [
                'success'   => false,
                'message'   => 'User does not exist'
            ];
        }
        JVB()->queue()->queueOperation(
            'rebuild_user_term_index',
            0,
@@ -452,7 +233,7 @@
            ],
            [
                'count'   => 1,
                'operation_id'      => 'rebuild_user_terms_' . date('Y_m_d')
                'operation_id'      => 'rebuild_user_'.$user_id.'_terms_' . date('Y_m_d')
            ]
        );
@@ -478,63 +259,64 @@
        }
        try {
            $results = [];
            foreach ($data['users'] as $user_id) {
                // Clear existing user records
                $this->wpdb->query($this->wpdb->prepare(
                    "DELETE FROM {$this->table_name} WHERE user_id = %d",
                    $user_id
                ));
                $this->index->delete(['user_id' => $user_id]);
                // Clear all caches for this user
                $this->clearUserCache($user_id);
                // Get all the user's published posts
                $posts = $this->wpdb->get_col($this->wpdb->prepare(
                    "SELECT ID FROM {$this->wpdb->posts}
                    WHERE post_author = %d
                    AND post_status = 'publish'
                    AND post_type LIKE %s",
                    $user_id,
                    $this->wpdb->esc_like(BASE) . '%'
                ));
                $posts = new WP_Query([
                    'post_status'   => 'publish',
                    'post_type'     => array_map('jvbCheckBase', Registrar::getRegistered('post')),
                    'posts_per_page'=> -1,
                    'fields'        => 'ids'
                ]);
                $processed_terms = 0;
                if (empty($posts->posts)) {
                    return [
                        'success'   => true,
                        'result'    => [
                            'user_id'   => $user_id,
                            'processed_posts'=> 0,
                            'processed_terms'=> 0,
                        ]
                    ];
                }
                foreach ($posts as $post_id) {
                    $post_type = get_post_type($post_id);
                    $taxonomies = get_object_taxonomies($post_type);
                $terms = [];
                    foreach ($taxonomies as $taxonomy) {
                        $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']);
                        $is_hierarchical = is_taxonomy_hierarchical($taxonomy);
                $taxonomies = [];
                $result=[
                    'user_id'   => $user_id,
                    'posts'     => count($posts->posts)
                ];
                foreach ($posts->posts as $postID) {
                    $postType = get_post_type($postID);
                    if (!array_key_exists($postType, $taxonomies)) {
                        $taxonomies[$postType] = get_object_taxonomies($postType);
                    }
                    $tax = $taxonomies[$postType];
                    $terms = array_unique(array_merge($terms, wp_get_object_terms($postID, $tax, ['fields' => 'ids'])));
                }
                        if (!is_wp_error($terms) && !empty($terms)) {
                            foreach ($terms as $term_id) {
                                // Add direct term
                                $this->updateUserTerm($user_id, $term_id, $taxonomy, false);
                                $processed_terms++;
                $result['terms'] = count($terms);
                                // Add parent terms for hierarchical taxonomies
                                if ($is_hierarchical) {
                                    $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy');
                                    foreach ($ancestors as $ancestor_id) {
                                        $this->updateUserTerm($user_id, $ancestor_id, $taxonomy, true);
                                        $processed_terms++;
                                    }
                                }
                            }
                        }
                    }
                }
                foreach ($terms as $termID) {
                    $taxonomy = get_term($termID)->taxonomy??false;
                    if (!$taxonomy) {
                        continue;
                    }
                    $this->updateUserTerm($user_id, $termID, $taxonomy);
                }
                $results[] = $result;
            }
            return [
                'success' => true,
                'result'    => [
                    'user_id' => $user_id,
                    'processed_posts' => count($posts),
                    'processed_terms' => $processed_terms
                ]
                'result'    => $results
            ];
        } catch (Exception $e) {
            JVB()->error()->log(
@@ -558,122 +340,29 @@
     *
     * @return array
     */
    public function getUserTerms(int $user_id, string $taxonomy, array $args = []):array
    public function fetchUserTerms(int $user_id, string $taxonomy, array $args = []):array
    {
        // Default arguments
        $defaults = [
            'orderby' => 'count',        // 'count' or 'name' or 'last_used'
            'order' => 'DESC',           // 'DESC' or 'ASC'
            'limit' => 0,                // 0 for no limit
            'min_count' => 1,            // Minimum usage count
            'include_parents' => true,   // Whether to include parent terms
            'only_direct' => false,      // If true, only returns directly-used terms
            'skip_cache' => false        // Whether to skip cache lookup
        ];
        $args = wp_parse_args($args, $defaults);
        // Skip cache and fetch directly
        return $this->fetchUserTerms($user_id, $taxonomy, $args);
    }
    /**
     * @param int $user_id
     * @param string $taxonomy
     * @param array $args
     *
     * @return array
     */
    private function fetchUserTerms(int $user_id, string $taxonomy, array $args):array
    {
        $taxonomy = jvbNoBase($taxonomy);
        if (!in_array($taxonomy, Registrar::getRegistered('term'))){
            return [];
        }
        $taxonomy = jvbCheckBase($taxonomy);
        $cache = Cache::for($user_id.'_term_relationships', DAY_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
        $key = $cache->generateKey(array_merge(
            [
                'taxonomy'  => $taxonomy,
            ],
            $args
        ));
        if (!$args['skip_cache']) {
            $cached = $cache->get($key);
            if ($cached) {
                return $cached;
            }
        }
        // Build query
        $query = "
            SELECT ut.term_id, ut.post_count, ut.last_used, ut.is_parent, t.name, t.slug
            FROM {$this->table_name} ut
            JOIN {$this->wpdb->terms} t ON ut.term_id = t.term_id
            WHERE ut.user_id = %d
            AND ut.taxonomy = %s
            AND ut.post_count >= %d
        ";
        $order = array_key_exists('order', $args) ? (!in_array(strtoupper($args['order']), ['ASC', 'DESC']) ? 'DESC' : $args['order']) : 'DESC';
        $orderby = array_key_exists('orderby', $args) ? (!in_array(strtolower($args['orderby']), ['post_count', 'last_used']) ? 'post_count' : $args['orderby']) : 'post_count';
        $limit = array_key_exists('limit', $args) ? absint($args['limit']) : 0;
        $limit = $limit === 0 ? null : $limit;
        $query_args = [
            $user_id,
            $taxonomy,
            $args['min_count']
        ];
        // Add parent term filter if needed
        if ($args['only_direct']) {
            $query .= " AND ut.is_parent = 0";
        } elseif (!$args['include_parents']) {
            $query .= " AND ut.is_parent = 0";
        }
        // Add ordering
        switch ($args['orderby']) {
            case 'name':
                $query .= " ORDER BY t.name " . ($args['order'] === 'DESC' ? 'DESC' : 'ASC');
                break;
            case 'last_used':
                $query .= " ORDER BY ut.last_used " . ($args['order'] === 'DESC' ? 'DESC' : 'ASC');
                break;
            case 'count':
            default:
                $query .= " ORDER BY ut.post_count " . ($args['order'] === 'DESC' ? 'DESC' : 'ASC');
                break;
        }
        // Add limit if specified
        if ($args['limit'] > 0) {
            $query .= " LIMIT %d";
            $query_args[] = $args['limit'];
        }
        // Execute query
        $results = $this->wpdb->get_results(
            $this->wpdb->prepare($query, $query_args),
            ARRAY_A
        );
        $cache->set($key, $results);
        return $results;
    }
    // Simple function to get just term IDs for a user
    /**
     * @param int $user_id
     * @param string $taxonomy
     * @param array $args
     *
     * @return array
     */
    public function getUserTermIDs(int $user_id, string $taxonomy, array $args = []):array
    {
        // Skip cache
        $terms = $this->getUserTerms($user_id, $taxonomy, array_merge($args, ['skip_cache' => true]));
        if (empty($terms)) {
            return [];
        }
        return array_map(function ($term) {
            return (int)$term['term_id'];
        }, $terms);
        return $this->index->pluck(
            'term_id',
            [
                'user_id'   => $user_id,
                'taxonomy'  => $taxonomy
            ],
            $orderby,
            $order,
            $limit
        );
    }
}
inc/managers/VerifyEntryManager.php
New file
@@ -0,0 +1,163 @@
<?php
namespace JVBase\managers;
if (!defined('ABSPATH')) {
    exit;
}
use JVBase\registrar\Registrar;
use JVBase\managers\CustomTable;
class VerifyEntryManager {
    protected CustomTable $table;
    protected int $max = 3;
    public function __construct()
    {
        $this->defineTables();
    }
    protected function defineTables():void
    {
        $types = implode(',', array_map(function($item) { return "`{$item}`"; },Registrar::getFeatured('verify_entry')));
        $table = CustomTable::for('verify_entry');
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'user_id'       => "{$table->getUserIDType()} NOT NULL",
            'content_id'    => "{$table->getPostIDType()} NOT NULL",
            'content_type'  => 'varchar(255) NOT NULL',
            'term_id'       => "{$table->getTermIDType()} NOT NULL",
            'taxonomy'      => "ENUM({$types}) NOT NULL",
            'status'        => "ENUM('requested', 'rejected','accepted') NOT NULL DEFAULT 'requested'",
            'result'        => 'JSON DEFAULT NULL',
            'notes'         => 'text DEFAULT NULL',
            'created_date'  => 'timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP',
            'updated_date'  => 'timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => 'content_term (`content_id`, `term_id`)'],
            'user_id (`user_id`)',
            'term_id (`term_id`)',
            'status (`term_id`, `status`)'
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}verify_entry_user` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}verify_entry_term` FOREIGN KEY (`term_id`)
            REFERENCES `{$table->getTermTable()}` (`term_id`) ON DELETE CASCADE",
        ]);
        $table->defineTable();
        $this->table = $table;
    }
    protected function response(?bool $success = null, string $message = ''):array
    {
        $response = [
            'success'   => is_null($success) ? 'partial' : $success,
        ];
        if (!empty($message)) {
            $response['message'] = $message;
        }
        return $response;
    }
    public function requestEntry(int $userID, int $termID, string $taxonomy):array
    {
        $user = get_userdata($userID);
        if (!$user || is_wp_error($user)) {
            return $this->response(false, 'User does not exist');
        }
        $term = get_term($termID, jvbCheckBase($taxonomy));
        if (!$term || is_wp_error($term)) {
            return $this->response(false, 'Term does not exist');
        }
        $profile = get_user_meta($userID, BASE.'link', true);
        if (empty($profile)) {
            return $this->response(false, 'No Profile found');
        }
        $managers = jvbGetTermOwners($termID);
        if (in_array($userID, $managers)) {
            wp_set_object_terms($profile, $termID, jvbCheckBase($taxonomy));
            return $this->response(true, 'User is manager');
        }
        $total = $this->table->pluck('user_id', [
            'user_id'   => $userID,
            'status'    => 'requested'
        ]);
        if (count($total) >= $this->max) {
            return $this->response(false, 'User already has maximum requests');
        }
        $existing = $this->table->get([
            'user_id'   => $userID,
            'term_id'   => $termID
        ]);
        if ($existing) {
            return $this->response(false, 'Existing pending request found. Please wait for response.');
        }
        $request = $this->table->insert([
            'user_id'   => $userID,
            'content_id'=> (int)$profile,
            'content_type'=> jvbNoBase(get_post_type($profile)),
            'term_id'   => $termID,
            'taxonomy'  => jvbNoBase($taxonomy)
        ]);
        if ($request && !empty($owners)) {
            JVB()->notification()->notify($owners,'entry_requested',$userID, [
                'target_id' => $termID,
                'target_type' => $taxonomy
            ]);
        }
        return $this->response(true, 'Request has been sent');
    }
    protected function actionEntry(bool $approve, int $userID, int $requestID, ?string $notes = null):array
    {
        $request = $this->table->get(['id' => $requestID]);
        if (!$request) {
            return $this->response(false, 'Request does not exist');
        }
        $termID = $request['term_id'];
        $owners = jvbGetTermOwners($termID);
        if (!user_can($userID, 'can_manage_'.$termID) || !in_array($userID, $owners)) {
            return $this->response(false, 'User does not exist');
        }
        $result = $this->table->update([
            'status'    => ($approve ? 'accepted' : 'rejected'),
            'notes'     => $notes
        ], [
            'id'    => $requestID
        ]);
        if ($approve) {
            wp_set_object_terms($request['content_id'], $termID, jvbCheckBase($request['taxonomy']));
        }
        JVB()->notification()?->notify($request['user_id'],$approve ? 'entry_approved' : 'entry_denied',$userID);
        //TODO: What if there are multiple managers, and one approved the request? Do we just delete the notification?
        JVB()->notification()?->unnotify($owners, 'entry_requested', $request['user_id'], [
            'target_id' => $termID,
            'target_type' => $request['taxonomy']
        ]);
        return $this->response(true, 'Request has been '. $approve ? 'approved' : 'denied');
    }
    public function approveEntry(int $userID, int $requestID, ?string $notes = null):array
    {
        return $this->actionEntry(true, $userID, $requestID, $notes);
    }
    public function denyEntry(int $userID, int $requestID, ?string $notes = null):array
    {
        return $this->actionEntry(false, $userID, $requestID, $notes);
    }
}
inc/managers/_setup.php
@@ -62,7 +62,12 @@
//}
if (Features::forSite()->has('notifications')) {
    require(JVB_DIR . '/inc/managers/Notifications/Content.php');
    require(JVB_DIR . '/inc/managers/Notifications/EmailDigests.php');
    require(JVB_DIR . '/inc/managers/Notifications/Notifications.php');
    require(JVB_DIR . '/inc/managers/Notifications/Preferences.php');
    require(JVB_DIR . '/inc/managers/NotificationManager.php');
}
if (Features::forMembership()->has('forum') && !empty(Registrar::getFeatured('is_content', 'term'))) {
inc/managers/queue/Queue.php
@@ -78,7 +78,7 @@
        ]);
        $queue->setKeys([
            ['key' => 'PRIMARY', 'value' => 'id'],
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            '`idx_run_queue` (`state`, `priority`, `scheduled_at`)',
            '`idx_user_ops` (`user_id`, `state`)',
            '`idx_user_type_pending` (`user_id`, `type`, `state`)',
@@ -111,7 +111,7 @@
        ]);
        $stats->setKeys([
            ['key' => 'PRIMARY', 'value' => 'id'],
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => '(`date`, `type`)'],
            '`date_idx` (`date`)',
            '`type_idx` (`type`)'
inc/registrar/Fields.php
@@ -166,8 +166,9 @@
    public function  modifyField(string $name, string $property, mixed $value):void
    {
        $property = 'set'.implode('',array_map('ucfirst',explode('_', $property)));
        $field = $this->fields[$name];
        $field->$property = $value;
        $field->$property($value);
    }
    public function getFields():array
inc/registrar/Posts.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\registrar;
use JVBase\forms\TaxonomySelector;
if (!defined('ABSPATH')) {
    exit;
}
@@ -208,9 +210,12 @@
     * @var string|false
     */
    public string|false $template_lock;
    protected string $unbased;
    protected ?string $taxonomyRewrite = null;
    public function __construct(string $postType, string $singular, string $plural)
    {
        $this->unbased = jvbNoBase($postType);
        $this->postType = jvbCheckBase($postType);
        $this->labels = $this->buildLabels($singular, $plural);
    }
@@ -312,6 +317,29 @@
        unset ($args['postType']);
//      error_log('Registering PostType '.$this->postType.', with args: '.print_r($args, true));
        $registrar = Registrar::getInstance($this->postType);
        $rewrite = $args['rewrite']['slug']??'';
        if ($registrar) {
            $hasSlug = array_key_exists('rewrite', $args) && array_key_exists('slug', $args['rewrite']);
            if ($registrar->rewrite_taxonomy && !str_contains($rewrite, '%')) {
                if (!$hasSlug && !array_key_exists('rewrite', $args)) {
                    $args['rewrite'] = [];
                }
                $tax = is_array($registrar->rewrite_taxonomy) ? $registrar->rewrite_taxonomy[array_key_first($registrar->rewrite_taxonomy)] : $registrar->rewrite_taxonomy;
                $args['rewrite']['slug'] = $hasSlug ? $rewrite."/%{$tax}%" : $this->unbased."/%{$tax}%";
            }
            if ($registrar->hasFeature('is_calendar')) {
                if (!$hasSlug && !array_key_exists('rewrite', $args)) {
                    $args['rewrite'] = [];
                }
                $args['rewrite']['slug'] = $hasSlug ? $rewrite."/%eyear%/%emonth%/%eday%" : $this->unbased."/%eyear%/%emonth%/%eday%";
            }
        }
        $registered = register_post_type($this->postType, $args);
        if (is_wp_error($registered)) {
            JVB()->error()->log('JVBase\registrar\Posts', 'Could not register post type', $registered->get_error_messages());
@@ -337,4 +365,93 @@
            'not_found_in_trash' => "No {$plural} found in Trash.",
        ];
    }
    public function addTaxonomyRewrite(string $taxonomy):void
    {
        $exists = Registrar::getInstance($taxonomy);
        if (!$exists) return;
        $this->taxonomyRewrite = $taxonomy;
        if (!isset($this->rewrite)) {
            $this->rewrite = [];
        }
        if (array_key_exists('slug', $this->rewrite)) {
            if (str_contains($this->rewrite['slug'], '%')) {
                return;
            }
            $this->rewrite['slug'] = $this->rewrite['slug'].'/%'.$taxonomy.'%';
        } else {
            $this->rewrite['slug'] = $this->unbased.'/%'.$taxonomy.'%';
        }
        $this->addTaxonomyRewriteRules();
        add_action('post_type_link', [$this, 'rewriteTaxonomySingle'], 15, 2);
        add_action('post_type_archive_link', [$this, 'rewriteTaxonomyArchive'], 15, 2);
    }
    public function addTaxonomyRewriteRules(): void
    {
        if (!$this->taxonomyRewrite) return;
        $tax = jvbCheckBase($this->taxonomyRewrite);
        // Rule 1: Post type archive - /faq/
        add_rewrite_rule(
            "{$this->unbased}/?$",
            "index.php?post_type={$this->postType}",
            'top'
        );
        // Rule 2: Single posts with taxonomy - /faq/section/post/
        add_rewrite_rule(
            "{$this->unbased}/([^/]+)/([^/]+)/?$",
            "index.php?post_type={$this->postType}&name=\$matches[2]&{$tax}=\$matches[1]",
            'top'
        );
        // Rule 3: Un-sectioned posts - /faq/post/
        // Use 'bottom' priority so taxonomy rules match first
        add_rewrite_rule(
            "{$this->unbased}/([^/]+)/?$",
            "index.php?post_type={$this->postType}&name=\$matches[1]",
            'bottom'
        );
    }
    /**
     * Set $this->rewrite_taxonomy to a valid taxonomy
     * @param string $url
     * @param \WP_Post $post
     * @return string
     */
    public function rewriteTaxonomySingle(string $url, \WP_Post $post): string
    {
        if ($post->post_type === $this->postType && !is_null($this->taxonomyRewrite)) {
            $type = $this->taxonomyRewrite;
            $taxonomy = jvbCheckBase($type);
            $terms = wp_get_post_terms($post->ID, $taxonomy);
            if (!empty($terms) && !is_wp_error($terms)) {
                $path = TaxonomySelector::getTermPath($terms[0], true);
                $path = implode('/', array_map(function($term) {
                    return sanitize_title($term);
                }, $path));
                return str_replace("%{$type}%", $path, $url);
            }
            return str_replace("/%{$type}%", '', $url);
        }
        return $url;
    }
    /**
     * Set $this->rewrite_taxonomy to a valid taxonomy
     * @param string $url
     * @param string $post_type
     * @return string
     */
    public function rewriteTaxonomyArchive(string $url, string $post_type):string
    {
        if ($post_type === $this->postType && !is_null($this->taxonomyRewrite)) {
            $url = get_home_url(null, "/{$this->postType}/");
        }
        return $url;
    }
}
inc/registrar/Registrar.php
@@ -1,9 +1,12 @@
<?php
namespace JVBase\registrar;
use JVBase\forms\TaxonomySelector;
use JVBase\inc\registrar\helpers\HideSingle;
use JVBase\managers\Cache;
use JVBase\managers\CRUD;
use JVBase\managers\IconsManager;
use JVBase\managers\KarmaManager;
use JVBase\meta\Meta;
use JVBase\registrar\config\Breadcrumbs;
use JVBase\registrar\config\Dashboard;
@@ -14,9 +17,11 @@
use JVBase\registrar\config\SEO;
use JVBase\registrar\helpers\AddIntegrationFields;
use JVBase\registrar\helpers\MakeCalendarType;
use JVBase\registrar\helpers\MakeTimelineType;
use JVBase\registrar\helpers\MakeTrackChanges;
use JVBase\registrar\helpers\MakeVerification;
use JVBase\utility\Features;
use WP_Post;
use WP_Query;
if (!defined('ABSPATH')) {
@@ -41,11 +46,13 @@
    protected int|false $page = false;
    public ?string $rewrite_taxonomy = null;
    protected static array $allFlags = [
        //Shared Flags
        'favouritable', 'karma', 'show_feed', 'show_directory', 'approve_new', 'has_responses', 'invitable',
        //Post Flags
        'hide_single', 'redirect_to_author', 'is_calendar', 'single_image', 'is_timeline', 'is_gallery',
        'hide_single', 'redirect_to_author', 'is_calendar', 'single_image', 'is_timeline', 'is_gallery', 'is_faq', 'is_glossary', 'rewrite_taxonomy',
        //Taxonomy Flags
        'is_content', 'is_ownable', 'verify_entry', 'track_changes', 'associate_user_content',
        //User Flags
@@ -62,6 +69,7 @@
     * @var bool Whether to setup karma for this content
     */
    protected bool $karma = false;
    protected ?KarmaManager $karmaManager = null;
    /**
     * @var bool Whether this should be available in the feed block
     */
@@ -90,6 +98,7 @@
     * @var bool Whether single items of this content should be hidden
     */
    protected bool $hide_single = false;
    protected ?HideSingle $hideSingleHandler = null;
    /**
     * @var bool Whether single items should just go to the author's page
@@ -98,11 +107,21 @@
    /**
     * @var bool Whether to make this a calendar type (example: events)
     */
    protected bool $is_calendar;
    protected bool $is_calendar = false;
    protected ?MakeCalendarType $isCalendarHandler = null;
    /**
     * @var bool Whether this is a before/after post type
     */
    protected bool $is_timeline = false;
    protected ?MakeTimelineType $isTimelineHandler = null;
    /**
     * @var bool Whether this is a defined term post type
     */
    protected bool $is_glossary = false;
    /**
     * @var bool Whether this is a faq post type
     */
    protected bool $is_faq = false;
    /**
     * @var bool Whether the uploader can group images prior to upload
     */
@@ -113,7 +132,7 @@
    /**
     * @var bool For taxonomy types only. Treats the taxonomy as a content (ie: tattoo shops))
     */
    protected bool $is_content;
    protected bool $is_content = false;
    /**
     * @var bool Whether this taxonomy can be owned/managed by specific people only
     */
@@ -122,10 +141,12 @@
     * @var bool Whether users/content need to request the owner for admission
     */
    protected bool $verify_entry;
    protected ?MakeVerification $verifyEntryHandler = null;
    /**
     * @var bool Whether we should track post movements from term to term (ie. artists in tattoo shops)
     */
    protected bool $track_changes;
    protected ?MakeTrackChanges $trackChangesHandler = null;
    /**
     * @var bool Whether any content by members in this taxonomy should show up in this taxonomy
@@ -158,7 +179,7 @@
    /**
     * @var bool Whether to generate a profile for this user role
     */
    protected bool $profile_link;
    public bool $profile_link;
    /**
     * @var array|string
     */
@@ -274,8 +295,16 @@
            return $this;
        }
        $this->args = $args;
        foreach ($args as $property => $value) {
            $this->registrar->$property = $value;
            if (property_exists($this->registrar, $property)) {
                $this->registrar->$property = $value;
            }
            if (property_exists($this, $property)) {
                $this->$property = $value;
            }
        }
        if (isset($this->icon) && !str_contains($this->icon, 'dashicons')){
            $this->registrar->menu_icon = IconsManager::for()->getCSSIcon($this->icon);
@@ -390,6 +419,30 @@
        return $this->based;
    }
    public function setGlossary(bool $set):self
    {
        $this->is_glossary = $set;
//      if ($set) {
//          $this->timeline = new MakeTimelineType($this->slug, $this);
//      }
        return $this;
    }
    public function isGlossary():bool
    {
        return $this->is_glossary;
    }
    public function setFaq(bool $set):self
    {
        $this->is_faq = $set;
//      if ($set) {
//          $this->timeline = new MakeTimelineType($this->slug, $this);
//      }
        return $this;
    }
    public function isFaq():bool
    {
        return $this->is_faq;
    }
    public function setTimeline(bool $set):self
    {
        $this->is_timeline = $set;
@@ -422,6 +475,9 @@
                case 'is_content':
                    add_action('init', [$this, 'setupContent'], 20);
                    break;
                case 'is_glossary':
                    $this->hide_single = true;
                    break;
            }
        }
        return $this;
@@ -447,13 +503,18 @@
    }
    public static function getFeatured(string $feature, ?string $type = null):array
    {
        self::ensureInstanced();
        if (!in_array($feature, static::$allFlags)) {
            error_log('Feature requested not found: '.$feature);
            return [];
        }
        return array_map(function($inst) { return $inst->slug; },array_filter(self::$instances, function ($inst) use ($feature, $type){
            return isset($inst->$feature) && $inst->$feature === true && (is_null($type) || $inst->type === $type);
            if (!is_null($type) && $inst->type !== $type) {
                return false;
            }
            return property_exists($inst, $feature) && isset($inst->$feature) && $inst->$feature === true;
        }));
    }
@@ -478,12 +539,15 @@
    }
        protected function getBreadcrumbs():Breadcrumbs
        {
            $this->breadcrumbs = new Breadcrumbs($this->slug, $this);
            if (!isset($this->breadcrumbs)) {
                $this->breadcrumbs = new Breadcrumbs($this->slug, $this);
            }
            return $this->breadcrumbs;
        }
        protected function getCalendar():MakeCalendarType|false
        {
            if ($this->is_calendar){
            if ($this->is_calendar && !isset($this->calendar)){
                $this->calendar = new MakeCalendarType($this->slug, $this);
            } else {
                $this->calendar = false;
@@ -493,26 +557,25 @@
        protected function getDashboard():Dashboard
        {
            $this->dashboard = new Dashboard($this->plural, $this);
            if (!isset($this->dashboard)) {
                $this->dashboard = new Dashboard($this->plural, $this);
            }
            return $this->dashboard;
        }
        protected function getDirectory():Directory|false
        {
            if ($this->show_directory) {
                $this->directory = new Directory($this->singular, $this);
            } else {
                $this->directory = false;
            if (!isset($this->directory)) {
                $this->directory = new Directory($this->singular);
            }
            return $this->directory;
        }
        protected function getFeed():Feed|false
        {
            if ($this->show_feed) {
                $this->feed = new Feed($this->slug, $this);
            } else {
                $this->feed = false;
            if (!isset($this->feed)) {
                $this->feed = new Feed($this->slug);
            }
            return $this->feed;
        }
@@ -520,7 +583,7 @@
        public function getSEO():SEO
        {
            if (!isset($this->seo)){
                $this->seo = new SEO($this->slug, $this);
                $this->seo = new SEO($this->slug);
            }
            return $this->seo;
        }
@@ -585,13 +648,48 @@
    public function register():void
    {
        if ($this->registrar) {
            $this->registrar->register();
        if ($this->type === 'post') {
            if ($this->hide_single) {
                $this->hideSingleHandler = new HideSingle($this->slug, $this);
            }
            if ($this->is_timeline) {
                $this->isTimelineHandler = new MakeTimelineType($this->slug, $this);
                $this->registrar->hierarchical = true;
            }
            if ($this->is_calendar) {
                $this->isCalendarHandler = new MakeCalendarType($this->slug, $this);
            }
            if (!is_null($this->rewrite_taxonomy)) {
                $this->registrar->addTaxonomyRewrite($this->rewrite_taxonomy);
            }
            if ($this->registrar) {
                $this->registrar->register();
            }
        } elseif ($this->type === 'term') {
            if ($this->is_content) {
                if ($this->verify_entry) {
                    $this->verifyEntryHandler = new MakeVerification();
                }
                if ($this->track_changes) {
                    $this->trackChangesHandler = new MakeTrackChanges($this->slug);
                }
            }
            if ($this->registrar) {
                $this->registrar->register();
            }
        }
        if ($this->karma) {
            $this->karmaManager = KarmaManager::for($this->slug);
        }
    }
    public static function getInstance(string $slug):Registrar|false
    {
        self::ensureInstanced();
        $slug = jvbNoBase($slug);
        if (array_key_exists($slug, static::$instances)) {
            return static::$instances[$slug];
@@ -601,6 +699,7 @@
    public static function getFieldsFor(string $slug):array
    {
        self::ensureInstanced();
        if (!array_key_exists($slug, static::$instances)) {
            return [];
        }
@@ -610,6 +709,7 @@
    public static function getRegistered(?string $type = null):array
    {
        self::ensureInstanced();
        $instances = ($type) ? array_filter(static::$instances, function($instance) use ($type) {
            return $instance->type === $type;
        }) : static::$instances;
@@ -618,6 +718,7 @@
    public static function getLabels():array
    {
        self::ensureInstanced();
        return array_map(function ($instance) {
            return ['singular' => $instance->getSingular(),
                'plural' => $instance->getPlural()];
@@ -797,4 +898,20 @@
        error_log('Built the '.$this->slug.' page content.');
        return $content . $out;
    }
    public static function ensureInstanced():void
    {
        if (empty(self::$instances)) {
            do_action('jvbDefineRegistrar');
            do_action('jvbDefineRegistrarFields');
        }
    }
    /*****************************************************************
     * FLAGGED FEATURES
    *****************************************************************/
}
inc/registrar/_setup.php
@@ -15,7 +15,10 @@
require(JVB_DIR . '/inc/registrar/fields/SelectorField.php');
require(JVB_DIR . '/inc/registrar/fields/UploadField.php');
require(JVB_DIR . '/inc/registrar/helpers/AddIntegrationFields.php');
require(JVB_DIR . '/inc/registrar/helpers/HideSingle.php');
require(JVB_DIR . '/inc/registrar/helpers/MakeCalendarType.php');
require(JVB_DIR . '/inc/registrar/helpers/MakeTimelineType.php');
require(JVB_DIR . '/inc/registrar/helpers/MakeTrackChanges.php');
require(JVB_DIR . '/inc/registrar/helpers/MakeVerification.php');
inc/registrar/config/Breadcrumbs.php
@@ -40,7 +40,7 @@
        $config = [
            'title' => $this->title,
        ];
        if (isset($this->addCrumb)) {
        if (!empty($this->addCrumb)) {
            $config['addCrumb'] = $this->addCrumb;
        }
        return $config;
inc/registrar/config/SEO.php
@@ -38,7 +38,7 @@
            if ($registrar) {
                switch ($registrar->getType()) {
                    case 'term':
                        $this->config['schema']['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                        $this->config['schema']['type'] = 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage';
                        break;
                    case 'post':
                        $this->config['schema']['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\CreativeWork';
inc/registrar/config/seo/Resolver.php
@@ -16,6 +16,13 @@
    public static function resolve(string $template, ?Meta $meta): string
    {
        if (!$meta) {
            if (is_singular()){
                $meta = Meta::forPost(get_the_ID());
            }else if (is_tax()) {
                $meta = Meta::forTerm(get_queried_object()->term_id);
            }
        }
        self::$meta = $meta;
        return preg_replace_callback(
            '/\{\{([^}]+)}}/',
@@ -45,6 +52,9 @@
            error_log('[SEO]Meta Resolver. Could not find meta configuration for variable: '.$variable);
            return '';
        }
        if ($variable === 'post_content') {
            return self::resolvePostContent($variable, $meta);
        }
        return match($config['type']) {
            'upload', 'image', 'gallery' => self::resolveImage($variable, $meta),
            'selector', 'taxonomy', 'user', 'post' => self::resolveObject($variable, $meta),
@@ -69,6 +79,8 @@
            }
        }
        error_log('resolveRelation: '.print_r($path, true));
        $ID = $meta->get($relation);
        if (!$ID || $ID === '') {
            return '';
@@ -78,14 +90,16 @@
        if (!$config) {
            return '';
        }
        $type = false;
        if ($config['type'] === 'taxonomy' || array_key_exists('taxonomy', $config)) {
            $type = 'taxonomy';
        } elseif ($config['type'] === 'user'){
            $type = 'term';
        } elseif ($config['type'] === 'user' || array_key_exists('user', $config)){
            $type = 'user';
        } elseif ($config['type'] === 'post'){
        } elseif ($config['type'] === 'post' || array_key_exists('post', $config)){
            $type = 'post';
        }
        if (!$type) {
            error_log('[SEO]Meta Resolver. Could not find type for relation: '.$relation.': '.$field);
            return '';
@@ -95,6 +109,9 @@
        return self::resolve($field, $newMeta);
    }
    protected static function resolvePostContent(string $variable, ?Meta $meta):string{
        return wp_strip_all_tags(str_replace("\n", '', $meta->get('post_content')));
    }
    protected static function resolveImage(string $variable, ?Meta $meta, bool $returnID = false): string
    {
        $imgID = $meta->get($variable);
@@ -133,7 +150,11 @@
            return $checkType;
        }
        error_log('[SEO]Resolver - No method found for '.$property.' with value: '.print_r($value, true).'. Defaulting to base Resolver');
        $ignore = ['description', 'name'];
        if (!in_array($property, $ignore)) {
            error_log('[SEO]Resolver - No method found for '.$property.' with value: '.print_r($value, true).'. Defaulting to base Resolver');
        }
        return self::resolve($value, $meta);
    }
inc/registrar/config/seo/Schema.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\registrar\config\seo;
use JVBase\base\SchemaHelper;
use JVBase\managers\Cache;
use JVBase\managers\SEO\render;
use JVBase\meta\Meta;
@@ -35,7 +36,7 @@
    ];
    protected array $defaultArchive = [
        'type'  => 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage',
        'type'  => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage',
        'title' => '{{registrar.plural}}'
    ];
@@ -58,7 +59,7 @@
            switch ($registrar->getType()) {
                case 'term':
                    $this->defaultSchema['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                    $this->defaultSchema['type'] = 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage';
                    break;
                case 'user':
                    $this->defaultSchema['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\ProfilePage';
@@ -79,7 +80,7 @@
        $registrar = Registrar::getInstance($this->slug);
        $this->defaultArchive = [
            'type'  => 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage',
            'type'  => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage',
            'name'  => $registrar->getPlural(),
            'description' => $registrar->getDescription()
        ];
@@ -148,6 +149,7 @@
    public function outputSchema():void
    {
        $registrar = Registrar::getInstance($this->slug);
        if (is_singular()) {
            $this->outputSingularSchema();
        } elseif (is_post_type_archive(jvbCheckBase($this->slug) || is_tax(jvbCheckBase($this->slug)))) {
@@ -163,10 +165,19 @@
                $this->cache->flush();
            }
            $registrar = Registrar::getInstance($this->slug);
            return $this->cache->remember(
                $ID,
                function () use ($ID) {
                function () use ($ID, $registrar) {
                    $meta = Meta::forPost($ID);
                    if ($registrar->hasFeature('is_faq')) {
                        return $this->outputQASchema($ID, $meta);
                    }
                    if ($registrar->hasFeature('is_timeline')) {
                        return $this->outputTimelineSchema($ID, $meta);
                    }
                    $config = $this->getConfig();
                    $class = JVB()->schemaHelper()::classFromConfig($config, $meta);
@@ -191,7 +202,7 @@
                    $config = JVB()->schemaHelper()::schema($action);
                    if (!array_key_exists('type', $config)) {
                        $config['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                        $config['type'] = 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage';
                    }
                    if (!class_exists($config['type'])) {
                        error_log('No class found for archive schema output: '.$config['type']);
@@ -238,12 +249,22 @@
                    $action = BASE.ucfirst($this->slug).'Archive';
                    $config = JVB()->schemaHelper()->archive($this->slug);
                    if (!array_key_exists('type', $config)) {
                        $config['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage';
                        $config['type'] = 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage';
                    }
                    if (!class_exists($config['type'])) {
                        error_log('No class found for archive schema output: '.$config['type']);
                        return [];
                    }
                    $registrar = Registrar::getInstance($this->slug);
                    if ($registrar->hasFeature('is_glossary')) {
                        return $this->outputGlossarySchema();
                    } else if ($registrar->hasFeature('is_faq')) {
                        return $this->outputFAQSchema();
                    }
                    if ($registrar->hasFeature('is_timeline')) {
                        return $this->outputTimelineArchiveSchema();
                    }
                    $obj = get_queried_object();
                    $meta = (property_exists($obj, 'taxonomy')) ? Meta::forTerm($obj->term_id) : null;
@@ -262,6 +283,7 @@
                    foreach ($items->posts as $ID) {
                        $item = $this->outputReferenceSchema($ID, 'post',false);
                        $listItem = new render\Thing\Intangible\ListItem();
                        $listItem->setId('listitem-'.$ID);
                        $listItem->setPosition($pos);
                        $listItem->setItem($item);
                        $itemListItems[] = $listItem;
@@ -301,7 +323,7 @@
                        error_log('Invalid type used for reference: '.print_r($type, true));
                        $meta = null;
                }
                $config = $this->getConfig('archive');
                $config = $this->getConfig();
                $class = JVB()->schemaHelper()::classFromConfig($config, $meta);
                $class->delete('about');
@@ -331,6 +353,28 @@
        return JVB()->schemaHelper()::getConfig($this->slug, $type);
    }
    public function resolveMeta(array $config, Meta $meta):array
    {
        foreach ($config as $property => $value) {
            if (is_array($value)) {
                $config[$property] = $this->resolveMeta($value, $meta);
                if ($property === 'additionalProperty' && (!array_key_exists('value', $config[$property]) || empty($config[$property]['value']))) {
                    unset($config[$property]);
                }
            }
            if (is_string($value) && str_contains($value, '{{')) {
                $value = Resolver::resolve($value, $meta);
                if (empty($value)){
                    unset($config[$property]);
                } else {
                    $config[$property] = $value;
                }
            }
        }
        return $config;
    }
    public function define(string $property, string $value):void
    {
        $class = $this->getConfig('schema')['type'];
@@ -392,7 +436,7 @@
        if (is_singular($based)){
            $config = $this->getConfig('meta');
            $meta = Meta::forPost(get_the_ID());
            $title = Resolver::resolve($config['name'], $meta);
            $title = Resolver::resolve($config['name']??$config['title'], $meta);
        } elseif (is_post_type_archive($based) ) {
            $config = $this->getConfig('archive');
            $title = $config['name'];
@@ -405,4 +449,210 @@
        }
        return $title;
    }
    public function outputQASchema(int $ID, Meta $meta):array
    {
        $registrar = Registrar::getInstance($this->slug);
        global $wp;
        $current = get_home_url(null, $wp->request).'/';
        $config = $this->getConfig();
        $config['type'] = 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\QAPage';
        $page = SchemaHelper::classFromConfig($config, $meta);
        $post = get_queried_object();
        $question = [
            'id'    => $current.'#question-'.$post->post_name,
            'type' => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Question',
            'name'  => $meta->get('post_title'),
            'acceptedAnswer' => [
                'id'    => $current.'#answer-'.$post->post_name,
                'type'  => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Answer',
                'text'  => wp_strip_all_tags(str_replace("\n", '', $meta->get('post_content'))),
            ],
        ];
        $question = SchemaHelper::classFromConfig($question);
        $page->setMainEntity($question);
        $page->setAuthor(JVB()->seo()->getCreator(true));
        return $page->outputSchema();
    }
    public function outputTimelineSchema(int $ID, Meta $meta):array
    {
        $registrar = Registrar::getInstance($this->slug);
        global $wp;
        $current = get_home_url(null, $wp->request).'/';
        $config = $this->getConfig();
        $config['type'] = 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\ImageGallery';
        $this->resolveMeta($config, $meta);
        $page = SchemaHelper::classFromConfig($config, $meta);
        $post = get_queried_object();
        $images = [];
        $children = new WP_Query([
            'post_type'=> jvbCheckBase($this->slug),
            'post_status'   => 'publish',
            'post_parent'   => $post->ID,
        ]);
        $children = $children->posts;
        array_unshift($children, $post);
        foreach ($children as $index => $child) {
            $meta = Meta::forPost($child->ID);
            $image = render\Thing\Thing::createImageFromID($meta->get('post_thumbnail'));
            $image->setId(($index === 0) ? '#before-treatment' : '#treatment-'.$index);
            $image->setPosition($index+1);
            $image->setName(($index === 0) ? 'Before Laser Tattoo Removal' : 'After '.$index.' Laser Tattoo Removal Treatments');
            if (!empty ($meta->get('post_excerpt'))){
                $image->setDescription($meta->get('post_excerpt'));
            }
            $images[] = $image;
        }
        $page->setImage($images);
        $page->setAuthor(JVB()->seo()->getCreator(true));
        return $page->outputSchema();
    }
    /*********************************************
     * Archive Presets
    *********************************************/
    public function outputFAQSchema():array
    {
        $registrar = Registrar::getInstance($this->slug);
        global $wp;
        $current = get_home_url(null, $wp->request).'/';
        $config = $this->getConfig('archive');
        $page = [
            'id'    => $current.'#'.$registrar->getSlug(),
            'type'  => 'JVBase\managers\SEO\render\Thing\CreativeWork\WebPage\FAQPage',
            'name'  => array_key_exists('name', $config) ? $config['name'] : $registrar->getPlural(),
            'description' => array_key_exists('description', $config) ? $config['description'] : $registrar->getDescription(),
            'url'   => $current,
        ];
        $page = SchemaHelper::classFromConfig($page);
        $args = [
            'post_type'     => $registrar->getBased(),
            'posts_per_page'=> -1,
            'post_status'   => 'publish',
        ];
        $obj = get_queried_object();
        if (property_exists($obj, 'taxonomy')) {
            $page->setName('FAQ on '.$obj->name);
            $args['post_type'] = array_map('jvbCheckBase', $registrar->registrar->for);
            $args['tax_query'] = [
                [
                    'taxonomy'  => $obj->taxonomy,
                    'terms'     => $obj->term_id,
                ]
            ];
        }
        $questions = [];
        $posts = new WP_Query($args);
        foreach ($posts->posts as $post) {
            $meta = Meta::forPost($post->ID);
            $question = [
                'id'    => $current.'#question-'.$post->post_name,
                'type' => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Question',
                'name'  => $meta->get('post_title'),
                'acceptedAnswer' => [
                    'id'    => $current.'#answer-'.$post->post_name,
                    'type'  => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\Comment\Answer',
                    'text'  => $meta->get('post_excerpt'),
                ],
                'url'   => get_the_permalink($post->ID),
            ];
            $questions[] = SchemaHelper::classFromConfig($question);
        }
        wp_reset_postdata();
        $page->setMainEntity($questions);
        return $page->outputSchema();
    }
    public function outputTimelineArchiveSchema():array
    {
        $registrar = Registrar::getInstance($this->slug);
        global $wp;
        $current = get_home_url(null, $wp->request).'/';
        $config = $this->getConfig('archive');
        $page = array_merge($config, [
            'id'    => $current.'#'.$registrar->getSlug(),
            'type'  => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage',
            'name'  => array_key_exists('name', $config) ? $config['name'] : $registrar->getPlural(),
            'description' => array_key_exists('description', $config) ? $config['description'] : $registrar->getDescription(),
            'url'   => $current,
        ]);
        $page = SchemaHelper::classFromConfig($page);
        $parts = [];
        $timelines = new WP_Query([
            'post_type'     => $registrar->getBased(),
            'posts_per_page'=> 50,
            'post_status'   => 'publish',
            'post_parent'   => 0,
        ]);
        foreach ($timelines->posts as $post) {
            $item = $this->outputReferenceSchema($post->ID, 'post', false);
            $item->setId($current.'#'.$post->post_name);
            $item->setName($post->post_title);
            $item->setUrl(get_the_permalink($post->ID));
            $parts[] = $item;
        }
        $page->setHasPart($parts);
        return $page->outputSchema();
    }
    public function outputGlossarySchema():array
    {
        $registrar = Registrar::getInstance($this->slug);
        global $wp;
        $current = get_home_url(null, $wp->request).'/';
        $config = $this->getConfig('archive');
        $page = [
            'id'    => $current.'#'.$registrar->getSlug(),
            'type'  => 'JVBase\inc\managers\SEO\render\Thing\CreativeWork\WebPage\CollectionPage\CollectionPage',
            'name'  => array_key_exists('name', $config) ? $config['name'] : $registrar->getPlural(),
            'description' => array_key_exists('description', $config) ? $config['description'] : $registrar->getDescription(),
            'url'   => $current,
        ];
        $page = SchemaHelper::classFromConfig($page);
        //Defined Termset
        $termset = [
            'type'  => 'JVBase\managers\SEO\render\Thing\CreativeWork\DefinedTermSet',
            'id'    => $current.'#definedtermset',
            'name'  => $registrar->getPlural(),
            'description' => $registrar->getDescription(),
        ];
        $termset = SchemaHelper::classFromConfig($termset);
        $terms = new WP_Query([
            'post_type' => $registrar->getBased(),
            'posts_per_page' => -1,
            'post_status' => 'publish',
        ]);
        $outputTerms = [];
        foreach ($terms->posts as $post) {
            $item = $this->outputReferenceSchema($post->ID, 'post', false);
            $item->setId($current.'#'.$post->post_name);
            $item->setName($post->post_title);
            $outputTerms[] = $item;
        }
        $termset->setHasDefinedTerm($outputTerms);
        $page->setMainEntity($termset);
        return $page->outputSchema();
    }
}
inc/registrar/fields/Field.php
@@ -19,6 +19,8 @@
    protected bool $quickEdit = true;   // whether to show in quick edit table
    protected bool $quill;              // whether to use quill
    protected int $maxLength;           // of characters
    protected string $subtype;
    protected array $allowedSubtype = ['text', 'url','number','tel','email','number'];
    /**
     * @var ?bool For timeline post types. Indicates whether all posts get this field, or just the parent
     */
@@ -50,7 +52,7 @@
        foreach ($config as $key => $value) {
            if (property_exists($class, $key)) {
                $method = 'set' . ucfirst($key);
                $method = 'set'.implode('',array_map('ucfirst',explode('_', $key)));;
                $class->$method($value);
            } else {
                error_log('Instance: '.print_r($class, true));
@@ -149,4 +151,16 @@
            }
        }, $config);
    }
    public function setSubtype(string $subtype):void
    {
        if (!in_array($subtype, $this->allowedSubtype)) {
            error_log('[SelectorField]Attempted subtype not allowed: '.$subtype);
            return;
        }
        $this->subtype = $subtype;
    }
    public function getSubtype():string
    {
        return $this->subtype;
    }
}
inc/registrar/fields/UploadField.php
@@ -12,26 +12,26 @@
    protected int $maxUploads;
    protected function setMultiple(bool $set):void
    public function setMultiple(bool $set):void
    {
        $this->multiple = $set;
    }
    protected function getMultiple():bool
    public function getMultiple():bool
    {
        return $this->multiple;
    }
    protected function setMaxUploads(int $maxUploads):void
    public function setMaxUploads(int $maxUploads):void
    {
        $max = 20;
        $this->maxUploads = min($maxUploads, $max);
    }
    protected function getMaxUploads():int
    public function getMaxUploads():int
    {
        return $this->maxUploads;
    }
    protected function setSubtype(string $subtype):void
    public function setSubtype(string $subtype):void
    {
        $allowed = ['document', 'video', 'image', 'all'];
        if (!in_array($subtype, $allowed)) {
@@ -40,7 +40,7 @@
        }
        $this->subtype = $subtype;
    }
    protected function getSubtype():string
    public function getSubtype():string
    {
        return $this->subtype;
    }
inc/registrar/helpers/HideSingle.php
New file
@@ -0,0 +1,103 @@
<?php
namespace JVBase\inc\registrar\helpers;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use WP_Post;
if (!defined('ABSPATH')) {
    exit;
}
class HideSingle {
    protected string $slug;
    protected string $postType;
    protected Registrar $registrar;
    public function __construct(string $slug, Registrar $registrar) {
        $this->slug = $slug;
        $this->postType = jvbCheckBase($slug);
        $this->registrar = $registrar;
        if ($this->registrar->hasFeature('hide_single')) {
            add_filter('is_post_type_viewable', [$this, 'hideFromPublic']);
            if ($this->registrar->hasFeature('redirect_to_author')) {
                add_filter('post_type_link', [$this, 'redirectSingleToAuthor'], 15, 2);
                add_action('template_redirect', [$this, 'actuallyRedirectToAuthor']);
            } else {
                add_filter('post_type_link', [$this, 'redirectSingleToArchive'], 15, 2);
                add_action('template_redirect', [$this, 'actuallyRedirectToArchive']);
            }
        }
    }
    /**
     * Use $this->hide_single
     * @param bool $is_viewable
     * @return bool
     */
    public function hideFromPublic(bool $is_viewable):bool
    {
        if (!is_admin() && is_singular($this->postType)) {
            return false;
        }
        return $is_viewable;
    }
    /**
     * Use $this->redirect_to_author for this method
     * @param string $url
     * @param \WP_Post $post
     * @return string
     */
    public function redirectSingleToAuthor(string $url, \WP_Post $post): string
    {
        if ($post->post_type !== $this->postType) {
            return $url;
        }
        // Redirect to author page or archive
        $user_link = jvbUserProfileLink($post->post_author);
        if ($user_link) {
            $query_var = str_replace(BASE, '', $post->post_type);
            return add_query_arg($query_var, $post->ID, get_permalink($user_link));
        }
        return get_post_type_archive_link($post->post_type);
    }
    public function actuallyRedirectToAuthor():void
    {
        if (is_singular($this->postType)) {
            global $post;
            $url = $this->redirectSingleToAuthor('', $post);
            $url = add_query_arg($this->slug, $post->ID, $url);
            wp_redirect($url, 301);
            exit;
        }
    }
    /**
     * Use $this->hide_single
     * @param string $url
     * @param \WP_Post $post
     * @return string
     */
    public function redirectSingleToArchive(string $url, \WP_Post $post): string
    {
        if ($post->post_type !== $this->postType) {
            return $url;
        }
        return get_post_type_archive_link($post->post_type).'#'.$post->post_name;
    }
    public function actuallyRedirectToArchive():void
    {
        if (is_singular($this->postType)) {
            global $post;
            $url = get_post_type_archive_link($this->postType).'#'.$post->post_name;
            wp_redirect($url, 301);
            exit;
        }
    }
}
inc/registrar/helpers/MakeTimelineType.php
New file
@@ -0,0 +1,47 @@
<?php
namespace JVBase\registrar\helpers;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use WP_Post;
if (!defined('ABSPATH')) {
    exit;
}
class MakeTimelineType {
    protected string $slug;
    protected string $postType;
    protected Registrar $registrar;
    public function __construct(string $slug, Registrar $registrar) {
        $this->slug = $slug;
        $this->postType = jvbCheckBase($slug);
        $this->registrar = $registrar;
        add_action('template_redirect', [$this, 'redirectChildToParent']);
    }
    /**
     * Set $this->is_timeline
     * @return void
     */
    public function redirectChildToParent(): void
    {
        if (!is_singular($this->postType)) {
            return;
        }
        global $post;
        // If this post has a parent, redirect to parent
        if ($post->post_parent) {
            $parent_url = get_permalink($post->post_parent);
            // Add anchor or query param to indicate which child was accessed
            $redirect_url = add_query_arg('update', $post->ID, $parent_url);
            wp_redirect($redirect_url, 301);
            exit;
        }
    }
}
inc/registrar/helpers/MakeTrackChanges.php
@@ -1,10 +1,131 @@
<?php
namespace JVBase\registrar\helpers;
use JVBase\managers\CustomTable;
if (!defined('ABSPATH')) {
    exit;
}
class MakeTrackChanges {
    protected string $slug;
    protected string $based;
    protected CustomTable $table;
    public function __construct(string $slug) {
        $this->slug = $slug;
        $this->based = jvbCheckBase($slug);
        $this->defineTables();
        add_action('set_object_terms', [$this, 'trackHistory'], 10, 6);
    }
    public function defineTables():void
    {
        $table = CustomTable::for('history_'.$this->slug);
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'user_id'       => "{$table->getUserIDType()} NOT NULL",
            'profile_id'    => "{$table->getPostIDType()} NOT NULL",
            'role'          => 'varchar(255) NOT NULL',
            'term_id'       => "{$table->getTermIDType()} NOT NULL",
            'start_date'    => 'date DEFAULT NULL',
            'end_date'      => 'date DEFAULT NULL',
            'locked'        => 'tinyint(1) NOT NULL DEFAULT 0',
            'created_at'    => 'timestamp DEFAULT CURRENT_TIMESTAMP',
            'updated_at'    => 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
        ]);
        $table->setKeys([
            ['key' => 'PRIMARY', 'value' => '(`id`)'],
            ['key' => 'UNIQUE', 'value' => '(`profile_id`, `term_id`, `start_date`)'],
            'content_role (`term_id`, `role`)',
            'user_id (`user_id`)',
            'profile_id (`profile_id`)',
            'term_id (`term_id`)',
        ]);
        $base = BASE;
        $table->setConstraints([
            "CONSTRAINT `{$base}{$this->slug}_history_user` FOREIGN KEY (`user_id`)
            REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE",
            "CONSTRAINT `{$base}{$this->slug}_history_profile` FOREIGN KEY (`profile_id`)
            REFERENCES `{$table->getPostTable()}` (`ID`) ON DELETE CASCADE"
        ]);
        $table->defineTable();
        $this->table = $table;
    }
    public function trackHistory(int $object_id, array $terms, array $tt_ids, string $taxonomy, bool $append, array $old_tt_ids):void
    {
        if ($taxonomy !== $this->based) {
            return;
        }
        $user = get_post_meta($object_id, BASE.'link', true);
        if (empty($author)) {
            $user = get_post($object_id)->post_author??false;
            if (!$user) {
                return;
            }
        }
        $userObj = get_userdata($user);
        if (!$userObj) {return;}
        $role = jvbUserRole($user);
        $new = array_diff($tt_ids, $old_tt_ids);
        $old = array_diff($old_tt_ids, $new);
        $this->table->transaction(
            function() use ($new, $old, $role, $user, $object_id) {
                foreach ($new as $newTerm) {
                    $termID = $this->getTermIDFromTTID($newTerm);
                    $this->table->findOrCreate([
                        'user_id'   => $user,
                        'profile_id'=> $object_id,
                        'role'      => $role,
                        'term_id'   => $termID,
                        'locked'    => 0
                    ], [
                        'start_date'    => date('Y-m-d')
                    ]);
                }
                foreach ($old as $oldTerm) {
                    $termID = $this->getTermIDFromTTID($oldTerm);
                    $this->table->update(
                        [
                            'end_date'  => date('Y-m-d'),
                            'locked'    => 1
                        ],
                        [
                            'user_id'   => $user,
                            'profile_id'=> $object_id,
                            'term_id'   => $termID,
                            'locked'    => 0
                        ]);
                }
            }
        );
    }
    /**
     * Helper function to get term_id from term_taxonomy_id
     * @param int $tt_id
     *
     * @return int
     */
    private function getTermIDFromTTID(int $tt_id):int
    {
        global $wpdb;
        return $wpdb->get_var($wpdb->prepare(
            "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE term_taxonomy_id = %d",
            $tt_id
        ));
    }
}
inc/registrar/helpers/MakeVerification.php
@@ -5,6 +5,25 @@
    exit;
}
use JVBase\registrar\Registrar;
use JVBase\managers\CustomTable;
class MakeVerification {
    protected CustomTable $table;
    public function __construct()
    {
        $this->defineTables();
    }
    protected function defineTables():void
    {
        $types = implode(',', array_map(function($item) { return "`{$item}`"; },Registrar::getFeatured('verify_entry')));
        $table = CustomTable::for('verify_entry');
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'user_id'       => "{$table->getUserIDType()} NOT NULL",
            'content'
        ])
    }
}
inc/registry/CheckCustomTables.php
@@ -440,7 +440,7 @@
    /******************************************************
     * Table Definitions
     *****************************************************/
// MOVED TO Queue.php
//    protected function queueTables():array
//    {
//
@@ -514,30 +514,31 @@
//        ];
//    }
    protected function errorLogTables():array
    {
        return [
            'error_log'=> "(
                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                `error_type` varchar(50) NOT NULL,
                `component` varchar(100) NOT NULL,
                `method` varchar(100) DEFAULT NULL,
                `page_url` varchar(255) DEFAULT NULL,
                `message` text NOT NULL,
                `context` JSON,
                `severity` varchar(20) NOT NULL,
                `user_id` {$this->userIDType} DEFAULT NULL,
                `user_was_logged_in` tinyint(1) NOT NULL,
                `source` enum('frontend','backend') NOT NULL,
                `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                KEY `created_at` (`created_at`),
                KEY `component_severity_date` (`component`, `severity`, `created_at`),
                KEY `error_type_date` (`error_type`, `created_at`),
                KEY `severity_date` (`severity`, `created_at`)
            )"
        ];
    }
//MOVED TO ErrorHandler.php
//  protected function errorLogTables():array
//  {
//      return [
//          'error_log'=> "(
//              `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//              `error_type` varchar(50) NOT NULL,
//              `component` varchar(100) NOT NULL,
//              `method` varchar(100) DEFAULT NULL,
//              `page_url` varchar(255) DEFAULT NULL,
//              `message` text NOT NULL,
//              `context` JSON,
//              `severity` varchar(20) NOT NULL,
//              `user_id` {$this->userIDType} DEFAULT NULL,
//              `user_was_logged_in` tinyint(1) NOT NULL,
//              `source` enum('frontend','backend') NOT NULL,
//              `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
//              PRIMARY KEY (`id`),
//              KEY `created_at` (`created_at`),
//              KEY `component_severity_date` (`component`, `severity`, `created_at`),
//              KEY `error_type_date` (`error_type`, `created_at`),
//              KEY `severity_date` (`severity`, `created_at`)
//          )"
//      ];
//  }
    protected function userIntegrationsTable():array
    {
@@ -559,445 +560,449 @@
            )"
        ];
    }
//MOVED TO NotificationManger.php
//    protected function notificationTables():array
//    {
//
//        return [
//            // Main notifications table
//            'notifications' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `owner_id` {$this->userIDType} NOT NULL,
//            `action_user_id` {$this->userIDType} NOT NULL,
//            `target_id` bigint(20) DEFAULT NULL,
//            `target_type` varchar(30) DEFAULT NULL,
//            `type` enum('new_favourite','new_artist','artist_approved','artist_invitation',
//                  'new_term','term_approved','term_rejected','list_shared',
//                  'system_message','shop_invitation') NOT NULL,
//            `status` enum('unread','read','actioned','dismissed') NOT NULL DEFAULT 'unread',
//            `priority` enum('low','normal','high') NOT NULL DEFAULT 'normal',
//            `message` varchar(255) DEFAULT NULL,
//            `context` JSON DEFAULT NULL,
//            `requires_action` tinyint(1) NOT NULL DEFAULT 0,
//            `action_taken` tinyint(1) NOT NULL DEFAULT 0,
//            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//            `read_at` datetime DEFAULT NULL,
//            `actioned_at` datetime DEFAULT NULL,
//            `emailed_at` datetime DEFAULT NULL,
//            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            KEY `user_status` (`owner_id`, `status`),
//            KEY `target_lookup` (`target_id`, `target_type`),
//            KEY `unread_notifications` (`owner_id`, `status`, `created_at`),
//            KEY `requires_action` (`owner_id`, `requires_action`, `action_taken`),
//            KEY `acting_user_lookup` (`owner_id`, `action_user_id`, `type`, `status`, `created_at`),
//            CONSTRAINT `{$this->base}notify_owner` FOREIGN KEY (`owner_id`)
//            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}action_id` FOREIGN KEY (`action_user_id`)
//            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//        )",
//
//
//            'notifications_content' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `user_id` {$this->userIDType} NOT NULL,
//            `date` date NOT NULL,
//            `frequency` enum('daily','weekly','monthly') NOT NULL,
//            `tattoo_count` int unsigned NOT NULL DEFAULT 0,
//            `artwork_count` int unsigned NOT NULL DEFAULT 0,
//            `piercing_count` int unsigned NOT NULL DEFAULT 0,
//            `event_count` int unsigned NOT NULL DEFAULT 0,
//            `news_count` int unsigned NOT NULL DEFAULT 0,
//            `offer_count` int unsigned NOT NULL DEFAULT 0,
//            `total_items` int unsigned NOT NULL DEFAULT 0,
//            `has_profile_update` tinyint(1) NOT NULL DEFAULT 0,
//            `new_items` JSON DEFAULT NULL,
//            `updated_items` JSON DEFAULT NULL,
//            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            UNIQUE KEY `artist_date_frequency` (`user_id`, `date`, `frequency`),
//            KEY `recent_content` (`date`, `frequency`),
//            KEY `artist_frequency` (`user_id`, `frequency`),
//            CONSTRAINT `{$this->base}content_artist` FOREIGN KEY (`user_id`)
//            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//        )",
//
//            'notifications_user_seen' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `user_id` {$this->userIDType} NOT NULL,
//            `content_notification_id` bigint(20) unsigned NOT NULL,
//            `status` enum('unread','read','dismissed') NOT NULL DEFAULT 'unread',
//            `read_at` datetime DEFAULT NULL,
//            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            UNIQUE KEY `user_content_notif` (`user_id`, `content_notification_id`),
//            KEY `user_status` (`user_id`, `status`),
//            CONSTRAINT `{$this->base}user_content_user` FOREIGN KEY (`user_id`)
//            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}user_content_notification` FOREIGN KEY (`content_notification_id`)
//            REFERENCES `{$this->prefixed}notifications_content` (`id`) ON DELETE CASCADE
//        )",
//
//            // User notification preferences
//            'notification_preferences' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `user_id` {$this->userIDType} NOT NULL,
//            `item_id` bigint(20) NOT NULL,
//            `notification_type` varchar(50) NOT NULL,
//            `frequency` enum('never','daily','weekly','monthly') DEFAULT 'never',
//            `last_sent` datetime DEFAULT NULL,
//            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
//            `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            UNIQUE KEY `user_type` (`user_id`, `item_id`),
//            KEY `user_frequency` (`user_id`, `frequency`),
//            KEY `frequency_lookup` (`frequency`, `last_sent`),
//            CONSTRAINT `{$this->base}notification_pref_user` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//        )",
//
//            // Notification digest scheduling and tracking
//            'notification_digests' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `user_id` {$this->userIDType} NOT NULL,
//            `frequency` enum('daily','weekly','monthly') NOT NULL,
//            `scheduled_at` datetime NOT NULL,
//            `sent_at` datetime DEFAULT NULL,
//            `status` enum('pending','processing','sent','failed') DEFAULT 'pending',
//            `notification_count` int unsigned DEFAULT 0,
//            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            KEY `scheduled_digests` (`frequency`, `scheduled_at`, `status`),
//            KEY `user_digests` (`user_id`, `frequency`),
//            CONSTRAINT `{$this->base}digest_user` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//        )",
//
//            // Analytics on notification interactions
//            'stats__notifications' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `notification_id` bigint(20) unsigned NOT NULL,
//            `user_id` {$this->userIDType} NOT NULL,
//            `action` varchar(30) NOT NULL,
//            `action_source` enum('web','email','app') DEFAULT 'web',
//            `action_details` JSON DEFAULT NULL,
//            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            KEY `notification_lookup` (`notification_id`),
//            KEY `user_actions` (`user_id`, `action`),
//            KEY `action_analysis` (`action`, `action_source`),
//            CONSTRAINT `{$this->base}metrics_user` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}metrics_notification` FOREIGN KEY (`notification_id`)
//                REFERENCES `{$this->prefixed}notifications` (`id`) ON DELETE CASCADE
//        )"
//        ];
//    }
    protected function notificationTables():array
    {
        return [
            // Main notifications table
            'notifications' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `owner_id` {$this->userIDType} NOT NULL,
            `action_user_id` {$this->userIDType} NOT NULL,
            `target_id` bigint(20) DEFAULT NULL,
            `target_type` varchar(30) DEFAULT NULL,
            `type` enum('new_favourite','new_artist','artist_approved','artist_invitation',
                  'new_term','term_approved','term_rejected','list_shared',
                  'system_message','shop_invitation') NOT NULL,
            `status` enum('unread','read','actioned','dismissed') NOT NULL DEFAULT 'unread',
            `priority` enum('low','normal','high') NOT NULL DEFAULT 'normal',
            `message` varchar(255) DEFAULT NULL,
            `context` JSON DEFAULT NULL,
            `requires_action` tinyint(1) NOT NULL DEFAULT 0,
            `action_taken` tinyint(1) NOT NULL DEFAULT 0,
            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            `read_at` datetime DEFAULT NULL,
            `actioned_at` datetime DEFAULT NULL,
            `emailed_at` datetime DEFAULT NULL,
            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            KEY `user_status` (`owner_id`, `status`),
            KEY `target_lookup` (`target_id`, `target_type`),
            KEY `unread_notifications` (`owner_id`, `status`, `created_at`),
            KEY `requires_action` (`owner_id`, `requires_action`, `action_taken`),
            KEY `acting_user_lookup` (`owner_id`, `action_user_id`, `type`, `status`, `created_at`),
            CONSTRAINT `{$this->base}notify_owner` FOREIGN KEY (`owner_id`)
            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}action_id` FOREIGN KEY (`action_user_id`)
            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
        )",
//MOVED TO ApprovalManager.php
//    protected function approvalTables($types):array
//    {
//        $tables = [];
//        $save = [];
//
//        foreach ($types as $type => $config) {
//            $save[$type] = ($type === 'term') ? $config : 'user';
//            $tables['approval_'.$type.'_requests'] = "(
//                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//                `user_id` {$this->userIDType} NOT NULL,
//                `status` enum('pending','approved','rejected','appealed','expired') DEFAULT 'pending',
//                `required_approvals` int unsigned DEFAULT 3,
//                `current_approvals` int unsigned DEFAULT 0,
//                `current_rejections` int unsigned DEFAULT 0,
//                `expires_at` datetime DEFAULT NULL,
//                `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
//                `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//                `approved_by` json DEFAULT NULL,
//                `rejected_by` json DEFAULT NULL,
//                `created_item` json DEFAULT NULL,
//                PRIMARY KEY (`id`),
//                KEY `status` (`status`),
//                KEY `expiring_requests` (`status`, `expires_at`),
//                CONSTRAINT `{$this->base}{$type}_approval_requester` FOREIGN KEY (`user_id`)
//                    REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//            )";
//            $tables['approval_'.$type.'_votes'] = "(
//                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//                `request_id` bigint(20) unsigned NOT NULL,
//                `user_id` {$this->userIDType} NOT NULL,
//                `vote` enum('approve','reject','dismissed') NOT NULL,
//                `notes` text DEFAULT NULL,
//                `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
//                PRIMARY KEY (`id`),
//                UNIQUE KEY `unique_vote` (`request_id`, `user_id`),
//                KEY `user_votes` (`user_id`),
//                CONSTRAINT `{$this->base}{$type}_user_approval_request` FOREIGN KEY (`request_id`)
//                    REFERENCES `{$this->prefixed}approval_{$type}_requests` (`id`) ON DELETE CASCADE,
//                CONSTRAINT `{$this->base}{$type}_user_approval_voter` FOREIGN KEY (`user_id`)
//                    REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//            )";
//        }
//        if (!empty($save)) {
//            update_option(BASE.'approvals_types', $save);
//        }
//        return $tables;
//    }
            'notifications_content' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `user_id` {$this->userIDType} NOT NULL,
            `date` date NOT NULL,
            `frequency` enum('daily','weekly','monthly') NOT NULL,
            `tattoo_count` int unsigned NOT NULL DEFAULT 0,
            `artwork_count` int unsigned NOT NULL DEFAULT 0,
            `piercing_count` int unsigned NOT NULL DEFAULT 0,
            `event_count` int unsigned NOT NULL DEFAULT 0,
            `news_count` int unsigned NOT NULL DEFAULT 0,
            `offer_count` int unsigned NOT NULL DEFAULT 0,
            `total_items` int unsigned NOT NULL DEFAULT 0,
            `has_profile_update` tinyint(1) NOT NULL DEFAULT 0,
            `new_items` JSON DEFAULT NULL,
            `updated_items` JSON DEFAULT NULL,
            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `artist_date_frequency` (`user_id`, `date`, `frequency`),
            KEY `recent_content` (`date`, `frequency`),
            KEY `artist_frequency` (`user_id`, `frequency`),
            CONSTRAINT `{$this->base}content_artist` FOREIGN KEY (`user_id`)
            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
        )",
//  protected function taxonomyRelationshipsTables():array
//  {
//      $tables = [
//          'taxonomy_relationships' => "(
//      `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//      `term_id` {$this->termIDType} NOT NULL,
//      `related_term_id` {$this->termIDType} NOT NULL,
//      `taxonomy` varchar(32) NOT NULL,
//      `related_taxonomy` varchar(32) NOT NULL,
//      `post_count` int(11) NOT NULL DEFAULT 0,
//      `is_direct` tinyint(1) NOT NULL DEFAULT 1,
//      `is_hierarchical` tinyint(1) NOT NULL DEFAULT 0,
//      `last_updated` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//      PRIMARY KEY (`id`),
//      KEY `term_id` (`term_id`),
//      KEY `related_term_id` (`related_term_id`),
//      KEY `taxonomy` (`taxonomy`),
//      KEY `related_taxonomy` (`related_taxonomy`),
//      UNIQUE KEY `term_relation` (`term_id`, `related_term_id`, `taxonomy`, `related_taxonomy`),
//      CONSTRAINT `{$this->base}tax_rel_term_id` FOREIGN KEY (`term_id`)
//          REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE,
//      CONSTRAINT `{$this->base}tax_rel_related_id` FOREIGN KEY (`related_term_id`)
//          REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
//  )"
//      ];
//
////        if ((array_key_exists('dashboard', $this->JVB_SITE) && $this->JVB_SITE['dashboard'] === true) || array_key_exists('use_feed_block', $this->JVB_SITE) && $this->JVB_SITE['use_feed_block']) {
//          $tables['user_term_index'] = "(
//      `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//      `user_id` {$this->userIDType} NOT NULL,
//      `term_id` {$this->termIDType} NOT NULL,
//      `taxonomy` varchar(32) NOT NULL,
//      `post_count` int(11) NOT NULL DEFAULT 1,
//      `is_parent` tinyint(1) NOT NULL DEFAULT 0,
//      `last_used` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//      PRIMARY KEY (`id`),
//      UNIQUE KEY `user_term` (`user_id`, `term_id`, `taxonomy`),
//      KEY `user_taxonomy` (`user_id`, `taxonomy`),
//      KEY `taxonomy` (`taxonomy`),
//      CONSTRAINT `{$this->base}user_term_user_fk` FOREIGN KEY (`user_id`)
//          REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//      CONSTRAINT `{$this->base}user_term_term_fk` FOREIGN KEY (`term_id`)
//          REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
//  )";
////        }
//
//      return $tables;
//  }
//MOVED TO FavouritesManager.php
//    protected function favouriteTables():array
//    {
//
//        return [
//            'favourites'    => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `user_id` {$this->userIDType} NOT NULL,
//            `type` varchar(50) NOT NULL,
//            `target_id` bigint(20) NOT NULL,
//            `notes` text DEFAULT NULL,
//            `date_added` datetime DEFAULT CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            UNIQUE KEY `unique_favourite` (`user_id`, `type`, `target_id`),
//            KEY `user_type` (`user_id`, `type`),
//            KEY `target_type` (`target_id`, `type`),
//            CONSTRAINT `{$this->base}favourites_user` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//        )",
//            'favourites_lists'  => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `user_id` {$this->userIDType} NOT NULL,
//            `name` varchar(255) NOT NULL,
//            `description` text,
//            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            KEY `user_lists` (`user_id`),
//            CONSTRAINT `{$this->base}list_user` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//        )",
//            'favourites_list_items' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `list_id` bigint(20) unsigned NOT NULL,
//            `item_type` varchar(50) NOT NULL,
//            `item_id` bigint(20) NOT NULL,
//            `favourite_id` bigint(20) unsigned DEFAULT NULL,
//            `added_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            UNIQUE KEY `unique_list_item` (`list_id`, `item_type`, `item_id`),
//            KEY `list_items` (`list_id`),
//            KEY `favourite_id` (`favourite_id`),
//            CONSTRAINT `{$this->base}list_items` FOREIGN KEY (`list_id`)
//                REFERENCES    `{$this->prefixed}favourites_lists` (`id`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}list_favourite` FOREIGN KEY (`favourite_id`)
//                REFERENCES `{$this->prefixed}favourites` (`id`) ON DELETE SET NULL
//        )",
//            'favourites_list_shares' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `list_id` bigint(20) unsigned NOT NULL,
//            `user_id` {$this->userIDType} NULL,
//            `email` varchar(255) NOT NULL,
//            `permission_type` enum('view', 'edit') NOT NULL DEFAULT 'view',
//            `status` enum('pending', 'accepted', 'rejected', 'revoked') NOT NULL DEFAULT 'pending',
//            `invitation_token` varchar(64) NULL,
//            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            UNIQUE KEY `unique_share_user` (`list_id`, `user_id`, `status`),
//            UNIQUE KEY `unique_share_email` (`list_id`, `email`, `status`),
//            KEY `list_shares` (`list_id`),
//            KEY `status_index` (`status`),
//            CONSTRAINT `{$this->base}share_list` FOREIGN KEY (`list_id`)
//                REFERENCES `{$this->prefixed}favourites_lists` (`id`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}share_user` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//        )",
//            'favourites_list_stats'    => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `item_type` varchar(50) NOT NULL,
//            `item_id` bigint(20) NOT NULL,
//            `list_count` int NOT NULL DEFAULT 0,
//            `last_added` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            UNIQUE KEY `unique_item_stat` (`item_type`, `item_id`),
//            KEY `item_stats` (`item_type`, `item_id`)
//        )",
//        ];
//    }
            'notifications_user_seen' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `user_id` {$this->userIDType} NOT NULL,
            `content_notification_id` bigint(20) unsigned NOT NULL,
            `status` enum('unread','read','dismissed') NOT NULL DEFAULT 'unread',
            `read_at` datetime DEFAULT NULL,
            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `user_content_notif` (`user_id`, `content_notification_id`),
            KEY `user_status` (`user_id`, `status`),
            CONSTRAINT `{$this->base}user_content_user` FOREIGN KEY (`user_id`)
            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}user_content_notification` FOREIGN KEY (`content_notification_id`)
            REFERENCES `{$this->prefixed}notifications_content` (`id`) ON DELETE CASCADE
        )",
//MOVED TO ForumManager.php
//    protected function newsRelationshipsTable():array
//    {
//        return [
//            'news_relationships'    => "(
//                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//                `shop_id` {$this->termIDType} NOT NULL,
//                `user_id` {$this->userIDType} NOT NULL,
//                `artist_id` {$this->postIDType} NOT NULL,
//                `news_count` int(10) unsigned NOT NULL DEFAULT 0,
//                `last_post_date` datetime DEFAULT NULL,
//                `last_updated` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//                PRIMARY KEY (`id`),
//                UNIQUE KEY `shop_user` (`shop_id`, `user_id`),
//                KEY `shop_id` (`shop_id`),
//                KEY `user_id` (`user_id`),
//                KEY `artist_id` (`artist_id`),
//            CONSTRAINT `{$this->base}nr_shop_news_shop` FOREIGN KEY (`shop_id`)
//                REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}nr_shop_news_user` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}nr_shop_news_artist` FOREIGN KEY (`artist_id`)
//                REFERENCES `{$this->wpdb->posts}` (`ID`) ON DELETE SET NULL
//        )"
//        ];
//    }
            // User notification preferences
            'notification_preferences' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `user_id` {$this->userIDType} NOT NULL,
            `item_id` bigint(20) NOT NULL,
            `notification_type` varchar(50) NOT NULL,
            `frequency` enum('never','daily','weekly','monthly') DEFAULT 'never',
            `last_sent` datetime DEFAULT NULL,
            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
            `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `user_type` (`user_id`, `item_id`),
            KEY `user_frequency` (`user_id`, `frequency`),
            KEY `frequency_lookup` (`frequency`, `last_sent`),
            CONSTRAINT `{$this->base}notification_pref_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
        )",
//MOVED TO ResponseManager.php
//    protected function responseTable():array
//    {
//        return [
//            'responses' => "(
//            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//            `item_id` {$this->postIDType} NOT NULL,
//            `content` text NOT NULL,
//            `user_id` {$this->userIDType} NOT NULL,
//            `parent_id` bigint(20) unsigned DEFAULT NULL,
//            `response` text NOT NULL,
//            `status` enum('published','hidden','flagged','deleted') DEFAULT 'published',
//            `is_user_deleted` tinyint(1) DEFAULT 0,
//            `upvotes` int NOT NULL DEFAULT 0,
//            `downvotes` int NOT NULL DEFAULT 0,
//            `karma` int NOT NULL DEFAULT 0,
//            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
//            `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//            PRIMARY KEY (`id`),
//            KEY `item_lookup` (`item_id`, `status`),
//            KEY `user_comments` (`user_id`),
//            KEY `parent_child` (`parent_id`),
//            KEY `karma_order` (`karma`),
//            CONSTRAINT `{$this->base}re_responses_news` FOREIGN KEY (`item_id`)
//                REFERENCES `{$this->wpdb->posts}` (`ID`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}re_responses_parent` FOREIGN KEY (`parent_id`)
//                REFERENCES `{$this->prefixed}responses` (`id`) ON DELETE SET NULL
//        )",
//            'karma_response' => "(
//                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//                `item_id` bigint(20) NOT NULL,
//                `user_id` {$this->userIDType} NOT NULL,
//                `vote` enum('up','down') NOT NULL,
//                `date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//                PRIMARY KEY (`id`),
//                UNIQUE KEY `user_post` (`user_id`,`item_id`),
//                KEY `item_id` (`item_id`),
//                KEY `user_id` (`user_id`),
//            CONSTRAINT `{$this->base}_response_item_id` FOREIGN KEY (`item_id`)
//                REFERENCES `{$this->prefixed}responses` (`id`) ON DELETE CASCADE,
//            CONSTRAINT `{$this->base}_response_user_id` FOREIGN KEY (`user_id`)
//                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//                )"
//        ];
//    }
            // Notification digest scheduling and tracking
            'notification_digests' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `user_id` {$this->userIDType} NOT NULL,
            `frequency` enum('daily','weekly','monthly') NOT NULL,
            `scheduled_at` datetime NOT NULL,
            `sent_at` datetime DEFAULT NULL,
            `status` enum('pending','processing','sent','failed') DEFAULT 'pending',
            `notification_count` int unsigned DEFAULT 0,
            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            KEY `scheduled_digests` (`frequency`, `scheduled_at`, `status`),
            KEY `user_digests` (`user_id`, `frequency`),
            CONSTRAINT `{$this->base}digest_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
        )",
            // Analytics on notification interactions
            'stats__notifications' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `notification_id` bigint(20) unsigned NOT NULL,
            `user_id` {$this->userIDType} NOT NULL,
            `action` varchar(30) NOT NULL,
            `action_source` enum('web','email','app') DEFAULT 'web',
            `action_details` JSON DEFAULT NULL,
            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            KEY `notification_lookup` (`notification_id`),
            KEY `user_actions` (`user_id`, `action`),
            KEY `action_analysis` (`action`, `action_source`),
            CONSTRAINT `{$this->base}metrics_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}metrics_notification` FOREIGN KEY (`notification_id`)
                REFERENCES `{$this->prefixed}notifications` (`id`) ON DELETE CASCADE
        )"
        ];
    }
    protected function approvalTables($types):array
    {
        $tables = [];
        $save = [];
        foreach ($types as $type => $config) {
            $save[$type] = ($type === 'term') ? $config : 'user';
            $tables['approval_'.$type.'_requests'] = "(
                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                `user_id` {$this->userIDType} NOT NULL,
                `status` enum('pending','approved','rejected','appealed','expired') DEFAULT 'pending',
                `required_approvals` int unsigned DEFAULT 3,
                `current_approvals` int unsigned DEFAULT 0,
                `current_rejections` int unsigned DEFAULT 0,
                `expires_at` datetime DEFAULT NULL,
                `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
                `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                `approved_by` json DEFAULT NULL,
                `rejected_by` json DEFAULT NULL,
                `created_item` json DEFAULT NULL,
                PRIMARY KEY (`id`),
                KEY `status` (`status`),
                KEY `expiring_requests` (`status`, `expires_at`),
                CONSTRAINT `{$this->base}{$type}_approval_requester` FOREIGN KEY (`user_id`)
                    REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
            )";
            $tables['approval_'.$type.'_votes'] = "(
                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                `request_id` bigint(20) unsigned NOT NULL,
                `user_id` {$this->userIDType} NOT NULL,
                `vote` enum('approve','reject','dismissed') NOT NULL,
                `notes` text DEFAULT NULL,
                `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                UNIQUE KEY `unique_vote` (`request_id`, `user_id`),
                KEY `user_votes` (`user_id`),
                CONSTRAINT `{$this->base}{$type}_user_approval_request` FOREIGN KEY (`request_id`)
                    REFERENCES `{$this->prefixed}approval_{$type}_requests` (`id`) ON DELETE CASCADE,
                CONSTRAINT `{$this->base}{$type}_user_approval_voter` FOREIGN KEY (`user_id`)
                    REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
            )";
        }
        if (!empty($save)) {
            update_option(BASE.'approvals_types', $save);
        }
        return $tables;
    }
    protected function taxonomyRelationshipsTables():array
    {
        $tables = [
            'taxonomy_relationships' => "(
        `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        `term_id` {$this->termIDType} NOT NULL,
        `related_term_id` {$this->termIDType} NOT NULL,
        `taxonomy` varchar(32) NOT NULL,
        `related_taxonomy` varchar(32) NOT NULL,
        `post_count` int(11) NOT NULL DEFAULT 0,
        `is_direct` tinyint(1) NOT NULL DEFAULT 1,
        `is_hierarchical` tinyint(1) NOT NULL DEFAULT 0,
        `last_updated` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        PRIMARY KEY (`id`),
        KEY `term_id` (`term_id`),
        KEY `related_term_id` (`related_term_id`),
        KEY `taxonomy` (`taxonomy`),
        KEY `related_taxonomy` (`related_taxonomy`),
        UNIQUE KEY `term_relation` (`term_id`, `related_term_id`, `taxonomy`, `related_taxonomy`),
        CONSTRAINT `{$this->base}tax_rel_term_id` FOREIGN KEY (`term_id`)
            REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE,
        CONSTRAINT `{$this->base}tax_rel_related_id` FOREIGN KEY (`related_term_id`)
            REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
    )"
        ];
        if ((array_key_exists('dashboard', $this->JVB_SITE) && $this->JVB_SITE['dashboard'] === true) || array_key_exists('use_feed_block', $this->JVB_SITE) && $this->JVB_SITE['use_feed_block']) {
            $tables['user_term_index'] = "(
        `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        `user_id` {$this->userIDType} NOT NULL,
        `term_id` {$this->termIDType} NOT NULL,
        `taxonomy` varchar(32) NOT NULL,
        `post_count` int(11) NOT NULL DEFAULT 1,
        `is_parent` tinyint(1) NOT NULL DEFAULT 0,
        `last_used` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        PRIMARY KEY (`id`),
        UNIQUE KEY `user_term` (`user_id`, `term_id`, `taxonomy`),
        KEY `user_taxonomy` (`user_id`, `taxonomy`),
        KEY `taxonomy` (`taxonomy`),
        CONSTRAINT `{$this->base}user_term_user_fk` FOREIGN KEY (`user_id`)
            REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
        CONSTRAINT `{$this->base}user_term_term_fk` FOREIGN KEY (`term_id`)
            REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
    )";
        }
        return $tables;
    }
    protected function favouriteTables():array
    {
        return [
            'favourites'    => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `user_id` {$this->userIDType} NOT NULL,
            `type` varchar(50) NOT NULL,
            `target_id` bigint(20) NOT NULL,
            `notes` text DEFAULT NULL,
            `date_added` datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `unique_favourite` (`user_id`, `type`, `target_id`),
            KEY `user_type` (`user_id`, `type`),
            KEY `target_type` (`target_id`, `type`),
            CONSTRAINT `{$this->base}favourites_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
        )",
            'favourites_lists'  => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `user_id` {$this->userIDType} NOT NULL,
            `name` varchar(255) NOT NULL,
            `description` text,
            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            KEY `user_lists` (`user_id`),
            CONSTRAINT `{$this->base}list_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
        )",
            'favourites_list_items' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `list_id` bigint(20) unsigned NOT NULL,
            `item_type` varchar(50) NOT NULL,
            `item_id` bigint(20) NOT NULL,
            `favourite_id` bigint(20) unsigned DEFAULT NULL,
            `added_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `unique_list_item` (`list_id`, `item_type`, `item_id`),
            KEY `list_items` (`list_id`),
            KEY `favourite_id` (`favourite_id`),
            CONSTRAINT `{$this->base}list_items` FOREIGN KEY (`list_id`)
                REFERENCES  `{$this->prefixed}favourites_lists` (`id`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}list_favourite` FOREIGN KEY (`favourite_id`)
                REFERENCES `{$this->prefixed}favourites` (`id`) ON DELETE SET NULL
        )",
            'favourites_list_shares' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `list_id` bigint(20) unsigned NOT NULL,
            `user_id` {$this->userIDType} NOT NULL,
            `email` varchar(255) NOT NULL,
            `permission_type` enum('view', 'edit') NOT NULL DEFAULT 'view',
            `status` enum('pending', 'accepted', 'rejected', 'revoked') NOT NULL DEFAULT 'pending',
            `invitation_token` varchar(64) NULL,
            `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `unique_share_user` (`list_id`, `user_id`, `status`),
            UNIQUE KEY `unique_share_email` (`list_id`, `email`, `status`),
            KEY `list_shares` (`list_id`),
            KEY `status_index` (`status`),
            CONSTRAINT `{$this->base}share_list` FOREIGN KEY (`list_id`)
                REFERENCES `{$this->prefixed}favourites_lists` (`id`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}share_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
        )",
            'favourites_list_stats'    => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `item_type` varchar(50) NOT NULL,
            `item_id` bigint(20) NOT NULL,
            `list_count` int NOT NULL DEFAULT 0,
            `last_added` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `unique_item_stat` (`item_type`, `item_id`),
            KEY `item_stats` (`item_type`, `item_id`)
        )",
        ];
    }
    protected function newsRelationshipsTable():array
    {
        return [
            'news_relationships'    => "(
                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                `shop_id` {$this->termIDType} NOT NULL,
                `user_id` {$this->userIDType} NOT NULL,
                `artist_id` {$this->postIDType} NOT NULL,
                `news_count` int(10) unsigned NOT NULL DEFAULT 0,
                `last_post_date` datetime DEFAULT NULL,
                `last_updated` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                UNIQUE KEY `shop_user` (`shop_id`, `user_id`),
                KEY `shop_id` (`shop_id`),
                KEY `user_id` (`user_id`),
                KEY `artist_id` (`artist_id`),
            CONSTRAINT `{$this->base}nr_shop_news_shop` FOREIGN KEY (`shop_id`)
                REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}nr_shop_news_user` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}nr_shop_news_artist` FOREIGN KEY (`artist_id`)
                REFERENCES `{$this->wpdb->posts}` (`ID`) ON DELETE SET NULL
        )"
        ];
    }
    protected function responseTable():array
    {
        return [
            'responses' => "(
            `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            `item_id` {$this->postIDType} NOT NULL,
            `content` text NOT NULL,
            `user_id` {$this->userIDType} NOT NULL,
            `parent_id` bigint(20) unsigned DEFAULT NULL,
            `response` text NOT NULL,
            `status` enum('published','hidden','flagged','deleted') DEFAULT 'published',
            `is_user_deleted` tinyint(1) DEFAULT 0,
            `upvotes` int NOT NULL DEFAULT 0,
            `downvotes` int NOT NULL DEFAULT 0,
            `karma` int NOT NULL DEFAULT 0,
            `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
            `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            KEY `item_lookup` (`item_id`, `status`),
            KEY `user_comments` (`user_id`),
            KEY `parent_child` (`parent_id`),
            KEY `karma_order` (`karma`),
            CONSTRAINT `{$this->base}re_responses_news` FOREIGN KEY (`item_id`)
                REFERENCES `{$this->wpdb->posts}` (`ID`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}re_responses_parent` FOREIGN KEY (`parent_id`)
                REFERENCES `{$this->prefixed}responses` (`id`) ON DELETE SET NULL
        )",
            'karma_response' => "(
                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                `item_id` bigint(20) NOT NULL,
                `user_id` {$this->userIDType} NOT NULL,
                `vote` enum('up','down') NOT NULL,
                `date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                UNIQUE KEY `user_post` (`user_id`,`item_id`),
                KEY `item_id` (`item_id`),
                KEY `user_id` (`user_id`),
            CONSTRAINT `{$this->base}_response_item_id` FOREIGN KEY (`item_id`)
                REFERENCES `{$this->prefixed}responses` (`id`) ON DELETE CASCADE,
            CONSTRAINT `{$this->base}_response_user_id` FOREIGN KEY (`user_id`)
                REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
                )"
        ];
    }
    protected function karmaTables(array $types):array
    {
        $tables = [];
        foreach ($types as $type => $config) {
            $t = false;
            if (array_key_exists($type, $this->JVB_CONTENT)) {
                $t = 'posts';
            } elseif (array_key_exists($type, $this->JVB_TAXONOMY)) {
                $t = 'terms';
            } elseif (array_key_exists($type, $this->JVB_USER)) {
                $t = 'users';
            }
            if (!$t) {
                continue;
            }
            switch ($t) {
                case 'posts':
                    $referenceType = $this->postIDType;
                    $reference_table = $this->wpdb->posts;
                    $reference_column = 'ID';
                    break;
                case 'terms':
                    $referenceType = $this->termIDType;
                    $reference_table = $this->wpdb->terms;
                    $reference_column = 'term_id';
                    break;
                case 'users':
                    $referenceType = $this->userIDType;
                    $reference_table = $this->userTable;
                    $reference_column = 'ID';
                    break;
            }
            $tables['karma_'.$type] = "(
                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                `item_id` {$referenceType} NOT NULL,
                `user_id` {$this->userIDType} NOT NULL,
                `vote` enum('up','down') NOT NULL,
                `date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                UNIQUE KEY `user_post` (`user_id`,`item_id`),
                KEY `item_id` (`item_id`),
                KEY `user_id` (`user_id`),
                CONSTRAINT `{$this->base}kt_{$type}_item_id` FOREIGN KEY (`item_id`)
                    REFERENCES `{$reference_table}` (`{$reference_column}`) ON DELETE CASCADE,
                CONSTRAINT `{$this->base}kt_{$type}_user_id` FOREIGN KEY (`user_id`)
                    REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
            )";
        }
        return $tables;
    }
    //MOVED TO KarmaManager.php
//    protected function karmaTables(array $types):array
//    {
//        $tables = [];
//        foreach ($types as $type => $config) {
//            $t = false;
//            if (array_key_exists($type, $this->JVB_CONTENT)) {
//                $t = 'posts';
//            } elseif (array_key_exists($type, $this->JVB_TAXONOMY)) {
//                $t = 'terms';
//            } elseif (array_key_exists($type, $this->JVB_USER)) {
//                $t = 'users';
//            }
//
//            if (!$t) {
//                continue;
//            }
//
//            switch ($t) {
//                case 'posts':
//                  $referenceType = $this->postIDType;
//                    $reference_table = $this->wpdb->posts;
//                    $reference_column = 'ID';
//                    break;
//                case 'terms':
//                  $referenceType = $this->termIDType;
//                    $reference_table = $this->wpdb->terms;
//                    $reference_column = 'term_id';
//                    break;
//                case 'users':
//                  $referenceType = $this->userIDType;
//                    $reference_table = $this->userTable;
//                    $reference_column = 'ID';
//                    break;
//            }
//
//            $tables['karma_'.$type] = "(
//                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//                `item_id` {$referenceType} NOT NULL,
//                `user_id` {$this->userIDType} NOT NULL,
//                `vote` enum('up','down') NOT NULL,
//                `date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
//                PRIMARY KEY (`id`),
//                UNIQUE KEY `user_post` (`user_id`,`item_id`),
//                KEY `item_id` (`item_id`),
//                KEY `user_id` (`user_id`),
//                CONSTRAINT `{$this->base}kt_{$type}_item_id` FOREIGN KEY (`item_id`)
//                    REFERENCES `{$reference_table}` (`{$reference_column}`) ON DELETE CASCADE,
//                CONSTRAINT `{$this->base}kt_{$type}_user_id` FOREIGN KEY (`user_id`)
//                    REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//            )";
//
//        }
//
//        return $tables;
//    }
    protected function calendarTables(array $types):array
    {
@@ -1159,7 +1164,7 @@
        ];
    }
    protected function invitationTables(array $config): array
    /*protected function invitationTables(array $config): array
    {
        if (empty($config['roles']) && empty($config['terms'])) {
            return [];
@@ -1212,177 +1217,180 @@
        $definitions .= ")";
        return ['invitations' => $definitions];
    }
    }*/
    protected function trackChangesTables($types)
    {
        $tables = [];
        foreach ($types as $type => $config) {
            $contents = $config['for_content'];
            foreach ($contents as $content) {
                $tables['history_'.$content.'_'.$type] = "(
                    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                    `user_id` {$this->userIDType} NOT NULL,
                    `content_id` bigint(20) NOT NULL,
                    `term_id` {$this->termIDType} NOT NULL,
                    `role` varchar(50) DEFAULT 'artist',
                    `is_primary` tinyint(1) DEFAULT 0,
                    `start_date` date DEFAULT NULL,
                    `end_date` date DEFAULT NULL,
                    `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
                    PRIMARY KEY (`id`),
                    UNIQUE KEY `content_term` (`content_id`, `term_id`),
                    KEY content_role (`term_id`, `role`),
                    CONSTRAINT `{$this->base}{$content}_{$type}_history_user` FOREIGN KEY (`user_id`)
                        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
                    CONSTRAINT `{$this->base}{$content}_{$type}_history_term` FOREIGN KEY (`term_id`)
                        REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
                )";
            }
        }
    //MOVED TO MakeTrackChanges.php
//    protected function trackChangesTables($types)
//    {
//        $tables = [];
//        foreach ($types as $type => $config) {
//            $contents = $config['for_content'];
//            foreach ($contents as $content) {
//                $tables['history_'.$content.'_'.$type] = "(
//                    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//                    `user_id` {$this->userIDType} NOT NULL,
//                    `content_id` bigint(20) NOT NULL,
//                    `term_id` {$this->termIDType} NOT NULL,
//                    `role` varchar(50) DEFAULT 'artist',
//                    `is_primary` tinyint(1) DEFAULT 0,
//                    `start_date` date DEFAULT NULL,
//                    `end_date` date DEFAULT NULL,
//                    `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
//                    PRIMARY KEY (`id`),
//                    UNIQUE KEY `content_term` (`content_id`, `term_id`),
//                    KEY content_role (`term_id`, `role`),
//                    CONSTRAINT `{$this->base}{$content}_{$type}_history_user` FOREIGN KEY (`user_id`)
//                        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//                    CONSTRAINT `{$this->base}{$content}_{$type}_history_term` FOREIGN KEY (`term_id`)
//                        REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
//                )";
//            }
//        }
//
//        return $tables;
//    }
        return $tables;
    }
    protected function requestEntryTables($types)
    {
        $tables = [];
        foreach ($types as $type => $config) {
            $contents = $config['for_content'];
            foreach ($contents as $content) {
                $tables[$content.'_'.$type.'_requests'] = "(
                    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
                    `user_id` {$this->userIDType} NOT NULL,
                    `content_id` bigint(20) NOT NULL,
                    `term_id` {$this->termIDType} NOT NULL,
                    `managers` json DEFAULT NULL,
                    `status` ENUM('requested', 'rejected', 'accepted') DEFAULT 'requested',
                    `dismissed` smallint(1) unsigned DEFAULT NULL,
                    `created_date` timestamp DEFAULT CURRENT_TIMESTAMP,
                    `updated_date` timestamp DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
                    `notes` text DEFAULT NULL,
                    PRIMARY KEY (`id`),
                    UNIQUE KEY `{$this->base}content_term` (`content_id`, `term_id`),
                    CONSTRAINT `{$this->base}{$content}_{$type}_request_user` FOREIGN KEY (`user_id`)
                        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
                    CONSTRAINT `{$this->base}{$content}_{$type}_request_term` FOREIGN KEY (`term_id`)
                        REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
                )";
            }
        }
//MOVED TO VerifyEntryManager.php
//    protected function requestEntryTables($types)
//    {
//        $tables = [];
//        foreach ($types as $type => $config) {
//            $contents = $config['for_content'];
//            foreach ($contents as $content) {
//                $tables[$content.'_'.$type.'_requests'] = "(
//                    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//                    `user_id` {$this->userIDType} NOT NULL,
//                    `content_id` bigint(20) NOT NULL,
//                    `term_id` {$this->termIDType} NOT NULL,
//                    `managers` json DEFAULT NULL,
//                    `status` ENUM('requested', 'rejected', 'accepted') DEFAULT 'requested',
//                    `dismissed` smallint(1) unsigned DEFAULT NULL,
//                    `created_date` timestamp DEFAULT CURRENT_TIMESTAMP,
//                    `updated_date` timestamp DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
//                    `notes` text DEFAULT NULL,
//                    PRIMARY KEY (`id`),
//                    UNIQUE KEY `{$this->base}content_term` (`content_id`, `term_id`),
//                    CONSTRAINT `{$this->base}{$content}_{$type}_request_user` FOREIGN KEY (`user_id`)
//                        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//                    CONSTRAINT `{$this->base}{$content}_{$type}_request_term` FOREIGN KEY (`term_id`)
//                        REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE
//                )";
//            }
//        }
//
//        return $tables;
//    }
        return $tables;
    }
// MOVED TO ReferralManager.php
    /**
     * Create referral tracking tables
     *
     * Call this from the main table creation method in CheckCustomTables.php:
     * $tables = array_merge($tables, $this->referralTables());
     */
    protected function referralTables(): array
    {
        // Create tables in dependency order
        // First: referrals (depends only on wp_users)
        $mainTable['referrals'] = "(
    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    `referrer_id` {$this->userIDType} NOT NULL,
    `referee_id` {$this->userIDType} NOT NULL,
    `referee_name` varchar(255) NOT NULL,
    `referee_email` varchar(255) NOT NULL,
    `referee_phone` varchar(50) DEFAULT NULL,
    `referral_code` varchar(50) NOT NULL,
    `status` enum('pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending',
    `referred_at` datetime NOT NULL,
    `consulted_at` datetime DEFAULT NULL,
    `treated_at` datetime DEFAULT NULL,
    `treatment_count` int DEFAULT 0,
    `notes` text DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `referee_unique` (`referee_id`),
    KEY `referrer_idx` (`referrer_id`),
    KEY `status_idx` (`status`),
    KEY `code_idx` (`referral_code`),
    KEY `date_idx` (`referred_at`),
    KEY `consult_idx` (`consulted_at`),
    CONSTRAINT `{$this->base}referral_referrer_fk` FOREIGN KEY (`referrer_id`)
        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
    CONSTRAINT `{$this->base}referral_referee_fk` FOREIGN KEY (`referee_id`)
        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
)";
        // Create the main referrals table first
        $this->createTables($mainTable);
        // Now create dependent tables
        $dependentTables = [];
        // Second: jane_clients (depends only on wp_users)
        $dependentTables['jane_clients'] = "(
    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    `patient_guid` varchar(50) NOT NULL,
    `user_id` {$this->userIDType} NOT NULL,
    `first_name` varchar(100) NOT NULL,
    `last_name` varchar(100) NOT NULL,
    `email` varchar(255) NOT NULL,
    `imported_at` datetime DEFAULT CURRENT_TIMESTAMP,
    `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `patient_guid_unique` (`patient_guid`),
    KEY `user_idx` (`user_id`),
    KEY `email_idx` (`email`),
    CONSTRAINT `{$this->base}jane_client_user_fk` FOREIGN KEY (`user_id`)
        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
)";
        // Third: referral_treatments (depends on referrals AND wp_users)
        $dependentTables['referral_treatments'] = "(
    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    `referral_id` bigint(20) unsigned NOT NULL,
    `user_id` {$this->userIDType} NOT NULL,
    `treatment_type` varchar(100) NOT NULL COMMENT 'Tier 1-6, Brows, etc',
    `treatment_date` datetime NOT NULL,
    `invoice_number` varchar(50) DEFAULT NULL,
    `amount` decimal(10,2) DEFAULT NULL,
    `status` enum('completed', 'no_show', 'cancelled') DEFAULT 'completed',
    `imported_at` datetime DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `referral_idx` (`referral_id`),
    KEY `user_idx` (`user_id`),
    KEY `date_idx` (`treatment_date`),
    KEY `type_idx` (`treatment_type`),
    CONSTRAINT `{$this->base}treatment_referral_fk` FOREIGN KEY (`referral_id`)
        REFERENCES `{$this->prefixed}referrals` (`id`) ON DELETE CASCADE,
    CONSTRAINT `{$this->base}treatment_user_fk` FOREIGN KEY (`user_id`)
        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
)";
        // Fourth: referral_rewards (depends on referrals AND wp_users)
        $dependentTables['referral_rewards'] = "(
    `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    `referral_id` bigint(20) unsigned NOT NULL,
    `user_id` {$this->userIDType} NOT NULL,
    `reward_type` enum('referrer', 'referee') NOT NULL,
    `amount` decimal(10,2) NOT NULL,
    `reward_calculation` varchar(20) DEFAULT NULL COMMENT 'percentage or fixed',
    `status` enum('available', 'redeemed', 'expired', 'cancelled') DEFAULT 'available',
    `created_at` datetime NOT NULL,
    `redeemed_at` datetime DEFAULT NULL,
    `expires_at` datetime DEFAULT NULL,
    `notes` text DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `referral_idx` (`referral_id`),
    KEY `user_idx` (`user_id`),
    KEY `status_idx` (`status`),
    KEY `type_idx` (`reward_type`),
    CONSTRAINT `{$this->base}reward_referral_fk` FOREIGN KEY (`referral_id`)
        REFERENCES `{$this->prefixed}referrals` (`id`) ON DELETE CASCADE,
    CONSTRAINT `{$this->base}reward_user_fk` FOREIGN KEY (`user_id`)
        REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
)";
        return $dependentTables;
    }
//  protected function referralTables(): array
//  {
//      // Create tables in dependency order
//      // First: referrals (depends only on wp_users)
//      $mainTable['referrals'] = "(
//  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//  `referrer_id` {$this->userIDType} NOT NULL,
//  `referee_id` {$this->userIDType} NOT NULL,
//  `referee_name` varchar(255) NOT NULL,
//  `referee_email` varchar(255) NOT NULL,
//  `referee_phone` varchar(50) DEFAULT NULL,
//  `referral_code` varchar(50) NOT NULL,
//  `status` enum('pending', 'consulted', 'treated', 'cancelled') DEFAULT 'pending',
//  `referred_at` datetime NOT NULL,
//  `consulted_at` datetime DEFAULT NULL,
//  `treated_at` datetime DEFAULT NULL,
//  `treatment_count` int DEFAULT 0,
//  `notes` text DEFAULT NULL,
//  PRIMARY KEY (`id`),
//  UNIQUE KEY `referee_unique` (`referee_id`, `referral_code`),
//  KEY `referrer_idx` (`referrer_id`),
//  KEY `status_idx` (`status`),
//  KEY `code_idx` (`referral_code`),
//  KEY `date_idx` (`referred_at`),
//  KEY `consult_idx` (`consulted_at`),
//  CONSTRAINT `{$this->base}referral_referrer_fk` FOREIGN KEY (`referrer_id`)
//      REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE,
//  CONSTRAINT `{$this->base}referral_referee_fk` FOREIGN KEY (`referee_id`)
//      REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//)";
//
//      // Create the main referrals table first
//      $this->createTables($mainTable);
//
//      // Now create dependent tables
//      $dependentTables = [];
//
//      // Second: jane_clients (depends only on wp_users)
//      $dependentTables['jane_clients'] = "(
//  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//  `patient_guid` varchar(50) NOT NULL,
//  `user_id` {$this->userIDType} NOT NULL,
//  `first_name` varchar(100) NOT NULL,
//  `last_name` varchar(100) NOT NULL,
//  `email` varchar(255) NOT NULL,
//  `imported_at` datetime DEFAULT CURRENT_TIMESTAMP,
//  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
//  PRIMARY KEY (`id`),
//  UNIQUE KEY `patient_guid_unique` (`patient_guid`),
//  KEY `user_idx` (`user_id`),
//  KEY `email_idx` (`email`),
//  CONSTRAINT `{$this->base}jane_client_user_fk` FOREIGN KEY (`user_id`)
//      REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//)";
//
//      // Third: referral_treatments (depends on referrals AND wp_users)
//      $dependentTables['referral_treatments'] = "(
//  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//  `referral_id` bigint(20) unsigned NOT NULL,
//  `user_id` {$this->userIDType} NOT NULL,
//  `treatment_type` varchar(100) NOT NULL COMMENT 'Tier 1-6, Brows, etc',
//  `treatment_date` datetime NOT NULL,
//  `invoice_number` varchar(50) DEFAULT NULL,
//  `amount` decimal(10,2) DEFAULT NULL,
//  `status` enum('completed', 'no_show', 'cancelled') DEFAULT 'completed',
//  `imported_at` datetime DEFAULT CURRENT_TIMESTAMP,
//  PRIMARY KEY (`id`),
//  KEY `referral_idx` (`referral_id`),
//  KEY `user_idx` (`user_id`),
//  KEY `date_idx` (`treatment_date`),
//  KEY `type_idx` (`treatment_type`),
//  CONSTRAINT `{$this->base}treatment_referral_fk` FOREIGN KEY (`referral_id`)
//      REFERENCES `{$this->prefixed}referrals` (`id`) ON DELETE CASCADE,
//  CONSTRAINT `{$this->base}treatment_user_fk` FOREIGN KEY (`user_id`)
//      REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//)";
//
//      // Fourth: referral_rewards (depends on referrals AND wp_users)
//      $dependentTables['referral_rewards'] = "(
//  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
//  `referral_id` bigint(20) unsigned NOT NULL,
//  `user_id` {$this->userIDType} NOT NULL,
//  `reward_type` enum('referrer', 'referee') NOT NULL,
//  `amount` decimal(10,2) NOT NULL,
//  `reward_calculation` varchar(20) DEFAULT NULL COMMENT 'percentage or fixed',
//  `status` enum('available', 'redeemed', 'expired', 'cancelled') DEFAULT 'available',
//  `created_at` datetime NOT NULL,
//  `redeemed_at` datetime DEFAULT NULL,
//  `expires_at` datetime DEFAULT NULL,
//  `notes` text DEFAULT NULL,
//  PRIMARY KEY (`id`),
//  KEY `referral_idx` (`referral_id`),
//  KEY `user_idx` (`user_id`),
//  KEY `status_idx` (`status`),
//  KEY `type_idx` (`reward_type`),
//  CONSTRAINT `{$this->base}reward_referral_fk` FOREIGN KEY (`referral_id`)
//      REFERENCES `{$this->prefixed}referrals` (`id`) ON DELETE CASCADE,
//  CONSTRAINT `{$this->base}reward_user_fk` FOREIGN KEY (`user_id`)
//      REFERENCES `{$this->userTable}` (`ID`) ON DELETE CASCADE
//)";
//
//      return $dependentTables;
//  }
    /*******************************************************************************************
     * These methods help create a content-type taxonomy, like the tattoo shops in edmonton.ink
inc/rest/routes/FavouritesRoutes.php
@@ -505,7 +505,7 @@
            'target_id' => $target_id
        ]);
        if ($result['created']) {
        if ((bool)$result) {
            $this->updateFavouriteCount($type, $target_id);
            $this->maybeNotifyOwner($type, $target_id, $user_id);
        }
@@ -582,13 +582,13 @@
                            'type' => $type,
                            'target_id' => $target_id
                        ]);
                        if ($result['created']) $results['added']++;
                        if ((bool) $result) $results['added']++;
                    } else {
                        $deleted = $table->where([
                        $deleted = $table->delete([
                            'user_id' => $user_id,
                            'type' => $type,
                            'target_id' => $target_id
                        ])->deleteResults();
                        ]);
                        if ($deleted) $results['removed']++;
                    }
inc/rest/routes/TermRoutes.php
@@ -1,9 +1,8 @@
<?php
namespace JVBase\rest\routes;
use JVBase\JVB;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\managers\TaxonomyRelationships;
use JVBase\managers\UserTermsManager;
use JVBase\rest\Route;
use JVBase\utility\Features;
@@ -89,7 +88,7 @@
        // Add relationship data if requested
        if ($request->get_param('include_relationships')) {
            $relationship_manager = new TaxonomyRelationships();
            $relationship_manager = JVB()->termRelationships();
            $related_taxonomies = $request->get_param('related_taxonomies') ?: ['jvb_style', 'jvb_theme', 'jvb_city'];
            $data['relationships'] = [];
@@ -194,8 +193,8 @@
        if (array_key_exists('content', $data)) {
            // If content_type is provided, use the specialized endpoint
            $content_type = $request->get_param('content');
            global $feed_types;
            if (taxIsJVBContentTax($content_type)) {
            $registrar = Registrar::getInstance($content_type);
            if ($registrar->hasFeature('is_content')) {
                $response = $this->getTermsForContentType($request);
                return $this->addCacheHeaders($response);
            }
@@ -239,7 +238,7 @@
            $main_context = json_decode($request->get_param('main_context'), true);
            $userID = get_post_meta($main_context['id'], BASE.'link', true);
            $manager = new UserTermsManager();
            $related = $manager->getUserTermIDs($userID, $taxonomy);
            $related = $manager->fetchUserTerms($userID, $taxonomy);
            if (empty($related)) {
                $response = $this->emptyResult();
@@ -252,7 +251,7 @@
            $main_context = json_decode($request->get_param('main_context'), true);
            $thisTaxonomy = str_replace('taxonomy:', '', $main_context['context']);
            $ID = (int)$main_context['id'];
            $manager = new TaxonomyRelationships();
            $manager = JVB()->termRelationships();
            $related = $manager->getRelatedTerms($ID, BASE.$request->get_param('taxonomy'));
            if (empty($related)) {
@@ -270,7 +269,7 @@
            $match = $request->get_param('match') ?? 'any';
            $context = json_decode($request['context'], true);
            $relationshipManager = new TaxonomyRelationships();
            $relationshipManager = JVB()->termRelationships();
            // Prepare array to collect term IDs that match the context
            $related_term_ids = [];
@@ -584,7 +583,7 @@
     */
    public function getTermsForContentType(WP_REST_Request $request):WP_REST_Response
    {
        $manager = new TaxonomyRelationships();
        $manager = JVB()->termRelationships();
        $content_type = BASE . $request->get_param('content');
        $taxonomy = BASE . $request->get_param('taxonomy');
        $search = $request->get_param('search');
inc/rest/routes/VoteRoutes.php
@@ -2,6 +2,7 @@
namespace JVBase\rest\routes;
use JVBase\managers\CustomTable;
use JVBase\managers\KarmaManager;
use JVBase\registrar\Registrar;
use JVBase\rest\Response;
use JVBase\rest\Rest;
@@ -65,7 +66,7 @@
        $vote = sanitize_text_field($request->get_param('vote')??'');
        $itemID = absint($request->get_param('item_id')??0);
        if ($itemID === 0 || !in_array($vote, ['up', 'down'])) {
        if ($itemID === 0 || !in_array($vote, [true, false, null])) {
            return Response::validationError(['message' => __('Invalid item or vote attempt', 'jvb')]);
        }
@@ -74,194 +75,27 @@
            return Response::validationError(['message' => __('User doesn\'t match. Bot?', 'jvb')]);
        }
        $operation = sanitize_text_field($request->get_param('id'));
        $type = Registrar::getInstance($content)->getType()??false;
        $type = $registrar->getType()??false;
        if (!$type) {
            return Response::validationError(['message' => __('Invalid content type', 'jvb')]);
        }
        $data = [
            'user'      => $user,
            'item_id'   => $itemID,
            'content'   => $content,
            'type'      => $type,
            'vote'      => $vote,
        ];
        error_log('Final Vote Data: '.print_r($data, true));
        error_log('Operation: '.print_r($operation, true));
        $operationID = JVB()->queue()->add(
            'karmic',
            $user,
            $data,
            [
                'priority'      => 'high',
                'operation_id'  => $operation,
            ]
        );
        return $this->queued($operationID['operation_id']);
    }
    /**
     * @param WP_Error|array $result
     * @param object $operation
     * @param array $data
     *
     * @return WP_Error|array
     * @throws Exception
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
    {
        if ($operation->type !== 'karmic') {
            return $result;
        }
        //Check if item exists
        $item = match ($data['type']) {
            'post'  => get_post($data['item_id']),
            'term'  => get_term($data['item_id'], jvbCheckBase($data['content'])),
            'user'  => get_userdata($data['item_id']),
            default => false
        };
        if (!$item || is_wp_error($item)) {
            return [
                'success' => false,
                'result'   => __('Invalid item', 'jvb')
            ];
        }
        $table = CustomTable::for('karma_' . $data['content']);
        return $table->transaction(function($table) use ($data) {
            // Check existing vote
            $existing = $table->where([
                'item_id' => $data['item_id'],
                'user_id' => $data['user']
            ])->first();
            $existing_vote = $existing->vote ?? null;
            $new_vote = $data['vote'];
            // No previous vote - insert new
            if ($existing_vote === null) {
                $inserted = $table->create([
                    'item_id' => $data['item_id'],
                    'user_id' => $data['user'],
                    'vote' => $new_vote,
                ]);
                if (!$inserted) {
                    throw new Exception('Failed to record vote');
                }
                $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $new_vote, 1);
                $this->cache->invalidate($data['user']);
                return [
                    'success' => true,
                    'result' => __('Vote recorded', 'jvb'),
                ];
            }
            // Changing vote
            if ($existing_vote !== $new_vote) {
                $updated = $table->where([
                    'item_id' => $data['item_id'],
                    'user_id' => $data['user']
                ])->updateResults(['vote' => $new_vote]);
                if (!$updated) {
                    throw new Exception('Failed to update vote');
                }
                // Decrement old, increment new
                $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $existing_vote, -1);
                $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $new_vote, 1);
                $this->cache->invalidate($data['user']);
                return [
                    'success' => true,
                    'result' => __('Vote updated', 'jvb'),
                ];
            }
            // Toggle off - remove vote
            $deleted = $table->where([
                'item_id' => $data['item_id'],
                'user_id' => $data['user']
            ])->deleteResults();
            if (!$deleted) {
                throw new Exception('Failed to remove vote');
            }
            $this->updateVoteCount($data['content'], $data['type'], $data['item_id'], $existing_vote, -1);
            $this->cache->delete($data['user']);
            return [
                'success' => true,
                'result' => __('Vote removed', 'jvb'),
            ];
        });
    }
    /**
     * @param string $content
     * @param int $ID
     * @param string $vote
     * @param int $value
     *
     * @return void
     */
    protected function updateVoteCount(string $content, string $type, int $ID, string $vote, int $value):void
    {
        $key = ($vote === 'down') ? BASE.'downvotes' : BASE.'upvotes';
        switch ($type) {
            case 'post':
                $old = (int) get_post_meta($ID, $key, true);
                $new = max(0, $old + $value);
                update_post_meta($ID, $key, $new);
                $up = (int) get_post_meta($ID, BASE.'upvotes', true);
                $down = (int) get_post_meta($ID, BASE.'downvotes', true);
                update_post_meta($ID, BASE.'karma', $up - $down);
                break;
            case 'term':
                $old = (int) get_term_meta($ID, $key, true);
                $new = max(0, $old + $value);
                update_term_meta($ID, $key, $new);
                $up = (int) get_term_meta($ID, BASE.'upvotes', true);
                $down = (int) get_term_meta($ID, BASE.'downvotes', true);
                update_term_meta($ID, BASE.'karma', $up - $down);
                break;
            case 'user':
                $old = (int) get_user_meta($ID, $key, true);
                $new = max(0, $old + $value);
                update_user_meta($ID, $key, $new);
                $up = (int) get_user_meta($ID, BASE.'upvotes', true);
                $down = (int) get_user_meta($ID, BASE.'downvotes', true);
                update_user_meta($ID, BASE.'karma', $up - $down);
                break;
            case 'response':
                $field = str_replace(BASE, '', $key);
                CustomTable::for('responses')->query(
                    "UPDATE {table}
                     SET $field = GREATEST(0, $field + %d),
                         karma = (upvotes - downvotes)
                     WHERE id = %d",
                    [$value, $ID]
                );
                break;
        $man = KarmaManager::getInstance($type);
        if (!$man) {
            return Response::validationError(['message' => __('Karma not set up', 'jvb')]);
        }
        [$success, $message] = $man->vote($user, $itemID, $content, $vote);
        return match ($success) {
            true, 'partial' => Response::success(['message' => $message]),
            default => Response::error($message),
        };
    }
    /**
     * @param WP_REST_Request $request
     *
@@ -276,25 +110,7 @@
            return Response::success($cache);
        }
        $votes = [];
        foreach (Registrar::getFeatured('has_karma') as $type) {
            $table = CustomTable::for('karma_' . $type);
            // Skip if table doesn't exist
            global $wpdb;
            if ($wpdb->get_var("SHOW TABLES LIKE '{$table->getFullTableName()}'") != $table->getFullTableName()) {
                continue;
            }
            $results = $table->where(['user_id' => $user])->getResults();
            if (!empty($results)) {
                foreach ($results as $vote) {
                    $votes[$type][$vote->item_id] = $vote->vote;
                }
            }
        }
        $votes = KarmaManager::getUserVotes($user);
        $this->cache->set($user, $votes);
inc/ui/CRUDSkeleton.php
@@ -901,7 +901,7 @@
        if ($limit) {
            if ($limit === 'user') {
                $manager = new UserTermsManager();
                return $manager->getUserTerms($this->user_id, $taxonomy);
                return $manager->fetchUserTerms($this->user_id, $taxonomy);
            } else {
                $limit = (int)$limit;
            }
jvb.php
@@ -245,6 +245,8 @@
require(JVB_DIR . '/inc/blocks/_setup.php');