Jake Vanderwerf
2026-04-26 86c6cd3cc099d2480932ede03c12cea01e625c94
inc/rest/routes/Invitations.php
@@ -1,1392 +1,333 @@
<?php
namespace JVBase\rest\routes;
use JVBase\JVB;
use JVBase\rest\RestRouteManager;
use Exception;
use JVBase\utility\Features;
use JVBase\rest\Rest;
use JVBase\managers\CustomTable;
use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
   exit;
}
// TODO: Get this to work with the constants setup
/***
 * WORKFLOW:
 *      1) Verified user (userA) invites user
 *          a) USER EXISTS -> notify user they're already up
 *          b) USER DOESN'T EXIST:
 *              i) check if user exists in invitation table
 *              ii) if they exist, add userA to inviters
 *                  - if status is expired, resent email invite and set status to 'pending'
 *              iii) if they don't exist, add to table
 *              iv) once invited user registers:
 *                  - set status to 'accepted', add new_user_id
 *                  - set user as verified
 *                  - if user was invited to a specific shop, pass user along to that shop
/**
 * Invitations Route Manager
 *
 * Handles user invitations:
 * - Global invitations (user to user, based on JVB_MEMBERSHIP['can_invite'])
 * - Term invitations (to ownable content taxonomies with 'invitable' flag)
 */
class Invitations extends RestRouteManager
class Invitations extends Rest
{
    protected string $tableName;
    protected array $inviteTypes;
    protected $wpdb;
    protected string $prefix;
    protected array $tableNames;
    protected int $expiryDays = 14; // Invitations expire after 14 days
   protected array $inviteConfig;
   protected CustomTable $table;
    public function __construct()
    {
        $this->cache_name = 'invitations';
        parent::__construct();
        global $wpdb;
        $this->inviteTypes = jvbInviteTableTypes();
        $this->tableNames = jvbInviteTables();
        $this->wpdb = $wpdb;
        $this->prefix = $wpdb->prefix;
   public function __construct()
   {
      $this->cacheName = 'invitations';
      parent::__construct();
        // Add hooks for processing accepted invitations
        add_action('user_register', [$this, 'checkInvitation'], 10, 1);
      // Get invitation configuration
      $this->inviteConfig = JVB()->invitations()->getInviteConfig();
      $this->table = CustomTable::for('invitations');
      add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2);
        add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']);
        // Add filter for bulk operation handling
        add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
    }
    /**
     * Registers the routes for invitations
     * @return void
     */
    public function registerRoutes():void
    {
        register_rest_route($this->namespace, '/invitations', [
            [
                'methods'   => 'GET',
                'callback'  => [$this, 'getInvitations'],
                'permission_callback'   => [$this, 'checkPermission']
            ],
            [
                'methods'   => 'POST',
                'callback'  => [$this, 'createInvitationRequest'],
                'permission_callback'   => [$this, 'checkPermission']
            ]
        ]);
    }
    protected function buildParams(object $request):array
    {
        $data = $request->get_params();
        $role = (array_key_exists('role', $data) && array_key_exists($data['role'], $this->tableNames)) ? $data['role'] : false;
        $toTerm = (array_key_exists('to_term', $data)) ? (int)$data['to_term'] : false;
        $taxonomy = (array_key_exists('taxonomy', $data) && in_array($data['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $data['taxonomy'] : false;
      return [
         'user'        => (array_key_exists('user', $data)) ? (int)$data['user'] : false,
         'role'         => $role,
         'to_term'     => $toTerm,
         'taxonomy'     => $taxonomy,
         'status'    => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all',
         'page'        => array_key_exists('page', $data) ? (int)$data['page'] : 1,
      ];
    }
    /**
     * @param object $request the request object
     *
     * @return WP_REST_Response
     */
    public function getInvitations(object $request): WP_REST_Response
    {
        $args = $this->buildParams($request);
        if ($args['user']) {
            if (!$this->userCheck($args['user'])) {
                return new WP_REST_Response([
                    'success'   => false,
                    'message'   => 'Looks like you are not who you say you are'
                ]);
            }
            if (!$this->isVerifiedUser($args['user'])) {
                return new WP_REST_Response([
                    'success'   => false,
                    'message'   => 'Sorry, you don\'t have permission to do this.',
                ]);
            }
            return $this->getUserInvitations($args);
        } elseif ($args['to_term']) {
            if (!$this->checkTerm($args)) {
                return new WP_REST_Response([
                    'success'   => false,
                    'message'   => 'Looks like this '.$args['taxonomy'].' does not exist'
                ]);
            }
            return $this->getTermInvitations($args);
        }
        return new WP_REST_Response([
            'success'   => false,
            'message'   => 'Invalid request'
        ]);
    }
    public function getTermInvitations(array $args):WP_REST_Response
    {
        if (!$this->checkTerm($args)) {
            return new WP_REST_Response([
                'success'   => false,
                'message'   => 'Invalid shop'
            ]);
        }
        if (!user_can($args['user'], 'manage_'.$args['taxonomy'].'_'.$args['to_term'])) {
            return new WP_REST_Response([
                'success'   => false,
                'message'   => 'You do not have permission to view invitations for this '.$args['taxonomy']
            ]);
        }
        $key = $this->cache->generateKey($args);
        $cache = $this->cache->get($key);
        if ($cache) {
            return new WP_REST_Response($cache);
        }
        $per_page = 20;
        $conditions = [];
        $params = [];
        //Filter by term
        $conditions[] = "to_{$args['taxonomy']} = %d";
        $params[] = $args['to_term'];
        if ($args['status'] !== 'all') {
            $conditions[] = "status = %s";
            $params[] = $args['status'];
        }
        $where = !empty($conditions) ? " WHERE " .implode(' AND ', $conditions) : "";
        //Count total for pagination
        $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}";
        $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params));
        //Get paginated invitations
        $offset = ($args['page'] - 1) * $per_page;
        $query = $count_query." ORDER BY created_at DESC LIMIT %d OFFSET %d";
        //Add pagination
        $pagination = array_merge($params, [$per_page, $offset]);
        $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination));
        $formatted = [];
        foreach ($invitations as $invitation) {
            $formatted[] = $this->formatInvitation($invitation);
        }
        $return = [
            'invitations'    => $formatted,
            'total'    => (int)$total,
            'pages'    => ceil($total /$per_page),
            'page'    => $args['page'],
            'per_page'    => $per_page
        ];
        $this->cache->set($key, $return);
        return new WP_REST_Response($return);
    }
    protected function buildInvitationArgs(object $request):array
    {
        $data = $request->get_params();
        $user = (array_key_exists('user', $data) && $this->userCheck($data['user'])) ? (int) $data['user'] : false;
        if (!$user) {
            return [];
        }
        $role = jvbUserRole($user);
        $args = [
            'user'          => $user,
            'role'          => $role,
            'action'        => (array_key_exists('action', $data) && in_array($data['action'], ['refresh', 'revoke', 'create'])) ? $data['action'] : false,
         'inviteID'     => (array_key_exists('refresh', $data)) ? (int) $data['refresh'] : false,
        ];
        $allowed = $this->inviteTypes[$role];
        if (count($allowed) > 1) {
            $inviteAs = (array_key_exists('type', $data) && in_array($data['type'], $allowed)) ? $data['type'] : false;
        } else {
            $invitedAs = $allowed[0];
        }
        if (array_key_exists('invites', $data)) {
            $invites = [];
            foreach ($data['invites'] as $invite) {
                $temp = [
                    'invited_id'    => (array_key_exists('invited_id', $invite) && $this->userCheck($invite['invited_id'])) ? $invite['invited_id'] : false,
                    'to_term'       => (array_key_exists('to_term', $invite)) ? (int) $invite['to_term'] : false,
                    'taxonomy'      => (array_key_exists('taxonomy', $invite) && in_array($invite['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $invite['taxonomy'] : false,
                    'invited_name'  => (array_key_exists('name', $invite) && is_string($invite['name'])) ? sanitize_text_field($invite['name']) : false,
                    'invited_email' => (array_key_exists('email', $invite) && is_email($invite['email'])) ? sanitize_email($invite['email']) : false,
                ];
                if ($temp['invited_id'] || ($temp['invited_email'] && $temp['invited_name'])) {
                    $invites[$invitedAs][] = $data;
                }
            }
            $args['invites'] = $invites;
        }
        if (!$invitedAs && !empty($args['invites'])) {
            unset($args['invites']);
        }
        return $args;
    }
    /**
     * @param object $request The Request Object
     *
     * @return WP_REST_Response
     */
    public function createInvitationRequest(object $request):WP_REST_Response
    {
        $args = $this->buildInvitationArgs($request);
        $error = '';
        if (!$args['user']) {
            $error = 'User ID doesn\'t match up.... are you a bot?';
        } elseif (Features::forMembership()->has('member_verified') && !user_can($args['user'], 'skip_moderation')) {
            $error = 'Only verified users can send invitations.';
        } elseif (!$args['role']) {
            $error = 'It doesn\'t look like you can invite users.';
        }
        if ($error !== '') {
            return new WP_REST_Response([
                'success'    => false,
                'message'    => $error
            ]);
        }
        switch ($args['action']) {
            case 'revoke':
                return $this->revokeInvite($args);
            case 'refresh':
                return $this->resendInvite($args);
        }
        //Inviting to content taxonomy (ie: shop)
        $artist = jvbContentFromUser($args['user']);
        foreach ($args['invites'] as $index => $invite) {
            if ($invite['to_term'] && $invite['taxonomy']) {
                if (!$artist[$invite['taxonomy']] || $artist[$invite['taxonomy']['id'] !== $invite['term_id']]) {
                    $args['invites'][$index]['to_term'] = false;
                    $args['invites'][$index]['taxonomy'] = false;
                }
            }
        }
        if (!empty($args['invites']??[])) {
            JVB()->queue()->queueOperation(
                'invitation_create',
                $args['user'],
                [
                    'invitations'   => $args['invites'],
                ],
                [
                    'count'   => count($args['invites']),
                    'priority'          => 'high',
               'chunk_size' => 20,
               'chunk_key' => 'invitations'
                ]
            );
            return new WP_REST_Response([
                'success' => true,
                'message' => 'Processing ' . count($args['invites']) . ' invitations',
            ]);
        }
        return new WP_REST_Response([
            'success'    => false,
            'message'    => 'No invitations sent.'
        ]);
    }
    /**
     * Revoke an invitation
     *
     * @params array $args
     * @return array Response with success or error message
     */
    public function revokeInvite(array $args): array
    {
        $invitation = $this->getInvitationByUser($args);
        if (!$invitation || is_wp_error($invitation)) {
            return [
                'success' => false,
                'result' => 'Invitation not found'
            ];
        }
        // Check if invitation can be revoked (only pending invitations)
        if ($invitation['status'] !== 'pending' && $invitation['status'] !== 'expired') {
            return [
                'success' => true,
                'result' => 'Only pending or expired invitations can be revoked'
            ];
        }
        // Check if the user is one of the inviters
        $inviters = json_decode($invitation['inviters'], true);
        $user_is_inviter = false;
        $updated_inviters = [];
        foreach ($inviters as $inviter) {
            if (intval($inviter['user_id']) === $args['user']) {
                $user_is_inviter = true;
            } else {
                // Keep other inviters
                $updated_inviters[] = $inviter;
            }
        }
        if (!$user_is_inviter) {
            return [
                'success' => false,
                'return' => 'You are not authorized to revoke this invitation'
            ];
        }
        // If there are still other inviters, just update the inviters list
        if (!empty($updated_inviters)) {
            $this->wpdb->update(
                $this->tableNames[$args['role']],
                [
                    'inviters' => json_encode($updated_inviters),
                    'updated_at' => current_time('mysql')
                ],
                ['id' => $invitation['id']]
            );
            return [
                'success' => true,
                'result' => 'You have been removed from the inviters list but the invitation is still active with other inviters',
            ];
        }
        // If no inviters left, mark the invitation as revoked
        $this->wpdb->update(
            $this->tableNames[$args['role']],
            [
                'status' => 'revoked',
                'updated_at' => current_time('mysql')
            ],
            ['id' => $invitation['id'] ]
        );
        $this->sendRevocationEmail($invitation['email'], $invitation['name']);
        return [
            'success' => true,
            'result' => 'Invitation has been successfully revoked'
        ];
    }
    /**
     * Resend an expired invitation
     *
     * @param array $args Args, as defined in buildInvitationArgs())
     * @return WP_REST_Response Response with success or error message
     */
    public function resendInvite(array $args): WP_REST_Response
    {
        $invitation_id = isset($args['inviteID']) ? intval($args['inviteID']) : 0;
        $user_id = isset($args['user']) ? intval($args['user']) : 0;
        if (!$invitation_id || !$user_id) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'Missing invitation ID or user ID'
            ]);
        }
        // Get the invitation
        $invitation = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->tableNames[$args['role']]} WHERE id = %d",
            $invitation_id
        ), ARRAY_A);
        if (!$invitation) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'Invitation not found'
            ]);
        }
        // Check if the invitation is expired or pending
        if (!in_array($invitation['status'], ['expired', 'pending'])) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'Only expired or pending invitations can be resent'
            ]);
        }
        // Check if the user is one of the inviters
        $inviters = json_decode($invitation['inviters'], true);
        $user_is_inviter = false;
        foreach ($inviters as &$inviter) {
            if (intval($inviter['user_id']) === $user_id) {
                $user_is_inviter = true;
                // Update the invited_at timestamp for this inviter
                $inviter['invited_at'] = current_time('mysql');
                break;
            }
        }
        if (!$user_is_inviter) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'You are not authorized to resend this invitation'
            ]);
        }
        // Generate a new token
        $token = wp_generate_password(32, false);
        // Set new expiration date
        $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
        // Update the invitation
        $this->wpdb->update(
            $this->tableNames[$args['role']],
            [
                'invitation_token' => $token,
                'status' => 'pending',
                'expires_at' => $expires_at,
                'inviters' => json_encode($inviters),
                'updated_at' => current_time('mysql')
            ],
            ['id' => $invitation_id]
        );
        // Send the invitation email again
        $name = $invitation['name'];
        $email = $invitation['email'];
      $role = $invitation['role'];
      $terms = $this->getInvitationTerms($invitation, $role);
        $result = $this->sendInvitationEmail($name, $email, $token, $user_id, $terms, $role);
        if (!$result) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'Failed to send invitation email'
            ]);
        }
        return new WP_REST_Response([
            'success' => true,
            'message' => 'Invitation has been successfully resent',
            'expires_at' => $expires_at
        ]);
    }
   protected function getInvitationTerms(object|array $invitation, string $role) {
      if (is_object($invitation)) {
         $invitation = json_decode(json_encode($invitation), true);
      }
      $terms = [];
      foreach ($this->inviteTypes[$role]['to_terms'] as $taxonomy) {
         $terms[$taxonomy] = $invitation['to_'.$taxonomy];
      }
      return $terms;
      // Cache connections
      $this->cache
         ->connect('user')
         ->connect('taxonomy');
   }
    /**
     * Create or update an invitation
     * @param string $name Name of person being invited
     * @param string $email Email of person being invited
     * @param int $inviter_id User ID of the person inviting
    * @param string|false $role
     * @param int|false $termID Optional shop ID
    * @param string|false $taxonomy Optional taxonomy
     * @param bool $send_email whether to send email right away
     * @return WP_Error|array
     *
     */
    public function createInvitation(
        string $name,
        string $email,
        int $inviter_id,
        string|false $role = false,
        int|false $termID = false,
        string|false $taxonomy = false,
        bool $send_email = true
    ):WP_Error|array {
        error_log('Creating Invitation with data: '.print_r([
            'name'      => $name,
            'email'     => $email,
            'inviter ID'=> $inviter_id,
            'termID'    => $termID,
            'taxonomy'    => $taxonomy,
            'role'        => $role
            ], true));
        // Sanitize and validate email
        $email = sanitize_email($email);
        if (!is_email($email)) {
            error_log('Invalid email');
            return new WP_Error('invalid_email', 'Invalid email address');
        }
   public function registerRoutes(): void
   {
      Route::for('invitations')
         ->get([$this, 'getInvitations'])
         ->args([
            'user' => 'int|required',
            'to_term' => 'int',
            'taxonomy' => 'string',
            'status' => 'string|enum:all,pending,accepted,rejected,expired,revoked|default:all',
            'page' => 'int|default:1|min:1'
         ])
         ->auth('user')
         ->rateLimit(20)
        // Check if inviter is verified
        if (Features::forMembership()->has('member_verified') && !$this->isVerifiedUser($inviter_id)) {
            error_log('Unverified Artist');
            return new WP_Error('unauthorized', 'Only verified artists can send invitations');
        }
         ->post([$this, 'createInvitationRequest'])
         ->args([
            'user' => 'int|required',
            'action' => 'string|enum:create,revoke,refresh|default:create',
            'invites' => 'array',
            'invitation_id' => 'int'
         ])
         ->auth('verified')
         ->rateLimit(10, 300)
         ->register();
   }
        if ($termID) {
            // Check if shop exists if specified
            if ($this->checkTerm(['term_id' => $termID, 'taxonomy' => $taxonomy])) {
                error_log('Invalid Taxonomy');
                return new WP_Error('invalid_term', 'The specified term does not exist');
            }
        }
   /**
    * Get invitations for a user or term
    */
   public function getInvitations(WP_REST_Request $request): WP_REST_Response
   {
      $userID = $request->get_param('user');
      $termID = $request->get_param('to_term');
      $taxonomy = $request->get_param('taxonomy');
        if (!$role || !array_key_exists($role, $this->inviteTypes)) {
            return new WP_Error('invalid_role', 'No role was set');
        }
        // Check if user already exists
        $invite = !email_exists($email);
        // Get existing invitation if any
        $existing = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->tableNames[$role]} WHERE email = %s",
            $email
        ), ARRAY_A);
        // Generate token
        $token = wp_generate_password(32, false);
        // Set expiration date
        $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days"));
        if ($existing) {
            // Update existing invitation
            $inviters = json_decode($existing['inviters'], true);
            // Check if this inviter already invited
            $inviter_exists = false;
            foreach ($inviters as $inviter) {
                if ($inviter['user_id'] == $inviter_id) {
                    $inviter_exists = true;
                    // Update the invited_at timestamp
                    $inviter['invited_at'] = current_time('mysql');
                    break;
                }
            }
            if (!$inviter_exists) {
                // Add new inviter
                $inviters[] = [
                    'user_id' => $inviter_id,
                    'invited_at' => current_time('mysql')
                ];
            }
            // Prepare update data
            $update_data = [
                'inviters'      => json_encode($inviters),
                'status'        => 'pending',
                'expires_at'    => $expires_at,
                'updated_at'    => current_time('mysql'),
            ];
            // Set shop_id if provided and not already set
         $check = 'to_'.$taxonomy;
            if ($termID && $existing[$check] !== $termID) {
                $update_data[$check] = $termID;
            }
            // If invitation was expired, generate new token
            if ($existing['status'] === 'expired') {
                $update_data['invitation_token'] = $token;
            } else {
                $token = $existing['invitation_token'];
            }
            $this->wpdb->update(
                $this->tableNames[$role],
                $update_data,
                ['id' => $existing['id']]
            );
            $invitation_id = $existing['id'];
        } else {
            // Create new invitation
            $inviters = [[
                'user_id' => $inviter_id,
                'invited_at' => current_time('mysql')
            ]];
            $insert_data = [
                'name'  => sanitize_text_field($name),
                'email' => $email,
                'invitation_token' => $token,
                'status' => 'pending',
                'inviters' => json_encode($inviters),
                'expires_at' => $expires_at,
                'created_at' => current_time('mysql')
            ];
            // Add shop if provided
            if ($termID) {
                $insert_data['to_'.$taxonomy] = $termID;
            }
            $this->wpdb->insert(
                $this->tableNames[$role],
                $insert_data
            );
            $invitation_id = $this->wpdb->insert_id;
        }
        error_log('On to invitation email send:');
        // Send invitation email
        if ($invite && $send_email) {
            $this->sendInvitationEmail($name, $email, $token, $inviter_id, [$taxonomy => $termID], $role);
        }
        return [
            'id' => $invitation_id,
            'email' => $email,
            'token' => $token,
            'expires_at' => $expires_at
        ];
    }
    /**
     * Validate an invitation token
     * @param string $token the generated token
     * @param string $email the email of the invited person
    * @param string $role the role
     * @return object $invitation or error
     */
    public function validateInvitation(string $token, string $email, string $role):object
    {
      if (!array_key_exists($role, $this->inviteTypes)) {
         return new WP_Error('invalid_role', 'Invalid role type');
      // Validate user
      if (get_current_user_id() !== $userID) {
         return $this->unauthorized('Invalid user');
      }
        $table = $this->wpdb->prefix . $this->tableNames[$role];
        // Get invitation by token and email
        $invitation = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM $table
             WHERE invitation_token = %s
             AND email = %s
             AND status = 'pending'",
            $token,
            $email
        ));
      $args = [
         'user' => $userID,
         'to_term' => $termID,
         'taxonomy' => $taxonomy ? jvbNoBase($taxonomy) : null,
         'status' => $request->get_param('status'),
         'page' => $request->get_param('page')
      ];
        if (!$invitation) {
            return new WP_Error('invalid_invitation', 'Invalid invitation token or email');
        }
      // Check cache
      $key = $this->cache->generateKey($args);
      if ($cached = $this->cache->get($key)) {
         return $this->success($cached);
      }
        // Check if expired
        if (strtotime($invitation->expires_at) < time()) {
            return new WP_Error('expired_invitation', 'This invitation has expired');
        }
      // Get appropriate invitations
      $result = ($args['to_term'] && $args['taxonomy'])
         ? $this->getTermInvitations($args)
         : $this->getUserInvitations($args);
        return $invitation;
    }
      // Cache result
      $this->cache->set($key, $result);
    /**
     * Send invitation email to the new artist
     * @param string $name The invited person's name
     * @param string $email The invited person's email
     * @param string $token The randomly generated password
     * @param int $inviter_id The User ID of the one inviting
     * @param int|null $shopID The optional shop ID to be invited to
     * @return bool Whether or not the invitation was sent successfully
     */
    protected function sendInvitationEmail(string $name, string $email, string $token, int $inviter_id, array $terms, string|null $role = null):bool
    {
        $inviter = get_userdata($inviter_id);
        $inviter_name = jvbGetUsername($inviter_id);
        $inviter_name = $inviter_name ?: $inviter->display_name;
      return $this->success($result);
   }
      $siteName = get_bloginfo('name');
   /**
    * Get invitations for a specific term
    */
   protected function getTermInvitations(array $args): array
   {
      // Check permission
      if (!JVB()->roles()->isManager($args['user'], $args['to_term'])) {
         return $this->forbidden('You cannot view invitations for this ' . $args['taxonomy'])->get_data();
      }
        $subject = apply_filters('jvbInvitationSubject',
         sprintf(
            "%s invited you to join %s!",
            $inviter_name,
            $siteName
         ),
         $inviter_name
      $perPage = 20;
      $offset = ($args['page'] - 1) * $perPage;
      // Build query
      $where = ['to_' . $args['taxonomy'] => $args['to_term']];
      if ($args['status'] !== 'all') {
         $where['status'] = $args['status'];
      }
      // Fluent CustomTable usage
      $total = $this->table
         ->where($where)
         ->countResults();
      $invitations = $this->table
         ->where($where)
         ->orderBy('created_at')
         ->limit($perPage, $offset)
         ->getResults();
      return [
         'invitations' => array_map([$this, 'formatInvitation'], $invitations),
         'total' => $total,
         'pages' => ceil($total / $perPage),
         'page' => $args['page'],
         'per_page' => $perPage
      ];
   }
   /**
    * Get invitations sent by a user
    */
   protected function getUserInvitations(array $args): array
   {
      $perPage = 20;
      $offset = ($args['page'] - 1) * $perPage;
      // Use raw query for JSON search
      $query = "SELECT * FROM {$this->table->getFullTableName()}
                  WHERE JSON_SEARCH(inviters, 'one', %s, NULL, '$[*].user_id') IS NOT NULL";
      $params = [$args['user']];
      if ($args['status'] !== 'all') {
         $query .= " AND status = %s";
         $params[] = $args['status'];
      }
      $query .= " ORDER BY created_at DESC LIMIT %d OFFSET %d";
      $params = array_merge($params, [$perPage, $offset]);
      $invitations = $this->table->queryResults($query, $params);
      // Get count
      $countQuery = str_replace('SELECT *', 'SELECT COUNT(*)',
         substr($query, 0, strrpos($query, 'ORDER BY')));
      $countParams = array_slice($params, 0, -2);
      $total = (int) $this->table->queryVar($countQuery, $countParams);
      return [
         'invitations' => array_map([$this, 'formatInvitation'], $invitations),
         'total' => $total,
         'pages' => ceil($total / $perPage),
         'page' => $args['page'],
         'per_page' => $perPage
      ];
   }
   /**
    * Create invitation request - queues invitations for processing
    */
   public function createInvitationRequest(WP_REST_Request $request): WP_REST_Response
   {
      $userID = $request->get_param('user');
      $action = $request->get_param('action');
      // Validate user
      if (get_current_user_id() !== $userID) {
         return $this->unauthorized('Invalid user');
      }
      // Handle actions
      return match($action) {
         'revoke' => $this->revokeInvite($userID, $request->get_params()),
         'refresh' => $this->resendInvite($userID, $request->get_params()),
         default => $this->queueInvitations($userID, $request->get_param('invites'))
      };
   }
   protected function queueInvitations(int $userID, array $invites): WP_REST_Response
   {
      if (empty($invites)) {
         return $this->error('No invitations provided');
      }
      // Validate invitations
      $validated = $this->validateInvitations($userID, $invites);
      if (empty($validated)) {
         return $this->error('No valid invitations to send');
      }
      // Queue using fluent interface
      $op = JVB()->queue()->add(
         'invitation_create',
         $userID,
         ['invitations' => $validated],
         [
            'priority' => 'high',
            'chunk_key' => 'invitations',
            'chunk_size' => 20
         ]
      );
      return $this->queued($op['operation_id']);
   }
   /**
    * Validate and sanitize invitation data
    */
   protected function validateInvitations(int $userID, array $invites): array
   {
      return JVB()->invitations()->validateInvitations($userID, $invites);
   }
   /**
    * Revoke an invitation
    */
   protected function revokeInvite(int $userID, array $data): WP_REST_Response
   {
      $invitationID = (int) ($data['invitation_id'] ?? 0);
      if (!$invitationID) {
         return $this->error('Invitation ID required');
      }
      $op = JVB()->queue()->add(
         'invitation_revoke',
         $userID,
         ['invitation_id' => $invitationID],
         ['priority' => 'high']
      );
        $signup_url = add_query_arg([
            'invite' => $token,
            'email' => urlencode($email),
            'name'  => $name,
         'role'   => $role
        ], wp_registration_url());
      return $this->queued($op['operation_id']);
   }
   /**
    * Resend an invitation
    */
   protected function resendInvite(int $userID, array $data): WP_REST_Response
   {
      $invitationID = (int) ($data['invitation_id'] ?? 0);
      if (!$invitationID) {
         return $this->error('Invitation ID required');
      }
      $op = JVB()->queue()->add(
         'invitation_resend',
         $userID,
         ['invitation_id' => $invitationID],
         ['priority' => 'high']
      );
      if (is_wp_error($op)) {
         return $this->error($op->get_error_message());
      }
      return $this->queued($op['operation_id']);
   }
        // Get shop name if applicable
        $toContentTax = [];
      if (!empty ($terms)) {
         foreach ($terms as $taxonomy => $termID) {
   /**
    * Format invitation for API response
    */
   protected function formatInvitation(object $invitation): array
   {
      $inviters = json_decode($invitation->inviters ?? '[]', true) ?: [];
      $formatted = [
         'id' => (int) $invitation->id,
         'name' => $invitation->name,
         'email' => $invitation->email,
         'invited_role' => $invitation->invited_role,
         'status' => $invitation->status,
         'expires_at' => $invitation->expires_at,
         'accepted_at' => $invitation->accepted_at ?? null,
         'created_at' => $invitation->created_at,
         'inviters' => array_map(fn($inviter) => [
            'id' => (int) $inviter['user_id'],
            'name' => jvbGetUsername($inviter['user_id']),
            'invited_at' => $inviter['invited_at']
         ], $inviters)
      ];
      // Add term information if present
      foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) {
         $column = 'to_' . $taxonomy;
         if (isset($invitation->$column) && $invitation->$column) {
            $termID = (int) $invitation->$column;
            $term = get_term($termID, BASE . $taxonomy);
            if ($term && !is_wp_error($term)) {
               $toContentTax[] = sprintf(
                  "<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>",
                  $inviter_name,
                  $term->name,
                  $taxonomy
               );
               $formatted['term'] = [
                  'id' => $termID,
                  'name' => $term->name,
                  'taxonomy' => $taxonomy
               ];
               break; // Only show first term
            }
         }
      }
      $toContentTax = implode(' ', $toContentTax);
        $button = JVB()->email()->button($signup_url, 'Join the Scene!');
        $link = JVB()->email()->link($signup_url);
        $signature = JVB()->email()->signature();
      $message = sprintf(
         '<p>Hi %s!</p>
            <p>%s has invited you to join them on %s.</p>
            <h2>Interested?</h2>
            <p>Join in by clicking the button below:</p>
            %s
            <p>Or by copying and pasting the link below into your browser:</p>
            %s
            <div class="divider"></div>
            %s
            <p>This invitation expires in %d days.</p>
            <p>Ink on, %s</p>
            %s
            ',
         $name,
         $inviter_name,
         $siteName,
         $button,
         $link,
         $name,
         $toContentTax,
         $this->expiryDays,
         $signature
      );
      $message = apply_filters('jvbInvitationMessage',
         $message,
         $name,
         $inviter_name,
         $role,
         $termID,
         $taxonomy,
         $toContentTax,
         $this->expiryDays,
         $button,
         $link,
         $signature,
      );
        $success = JVB()->email()->sendEmail($email, $subject, $message);
        if (!$success) {
            // Log the invitation
            JVB()->error()->log(
                'invitation_email',
                'Invitation not sent',
                [
                    'email' => $email,
                    'inviter_id' => $inviter_id,
                    'token' => $token
                ],
                'info'
            );
        }
        return $success;
    }
    /**
     * Send revocation email notification
     * @param string $email the invited person's email
     * @param string $name the invited person's name
     * @return bool Whether or not the email was sent
     */
    protected function sendRevocationEmail(string $email, string $name):bool
    {
      $siteName = get_bloginfo('name');
        $subject = apply_filters(
         'jvbInvitationRevokedSubject',
         sprintf(
            '[%s] Your invitation has been revoked',
            $siteName
         )
      );
      $content = apply_filters(
         'jvbInvitationRevokedMessage',
         sprintf(
            '<p>Hey %s,</p>
            <p>This is to let you know that your invitation to join %s has been revoked.</p>
            <p>If you believe this was done in error, please contact the person who invited you, the site admin, or try registering yourself.</p>',
            $name,
            $siteName
         ),
         $name
      );
        $success =  JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED');
        if (!$success) {
            JVB()->error()->log(
                'invitation_revoke_email',
                'Invitation not sent',
                [
                    'email' => $email,
                    'name' => $name,
                ],
                'info'
            );
        }
        return $success;
    }
    /**
     * Verify an invitation token
     * @param string $token The randomly generated token
     * @param string $email The invited person's email
     * @return bool|object False on failure. Invitation object if success
     */
    public function verifyInvitation(string $token, string $email, string $role):bool|object
    {
        $invitation = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->tableNames[$role]}
            WHERE invitation_token = %s AND email = %s AND status = 'pending' AND expires_at > NOW()",
            $token,
            $email
        ));
        if (!$invitation) {
            return false;
        }
        return $invitation;
    }
    /**
     * Mark an invitation as accepted
     * @param string $token The randomly generated token
     * @param string $email The invited person's email
     * @param int $user_id The invited person's user ID
     * @return bool whether or not it was successfully accepted
     */
    public function acceptInvitation(string $token, string $email, int $user_id):bool
    {
      $role = jvbUserRole($user_id);
        $invitation = $this->verifyInvitation($token, $email, $role);
        if (!$invitation) {
            return false;
        }
        // Update invitation status
        $this->wpdb->update(
            $this->tableNames[$role],
            [
                'status' => 'accepted',
                'new_user_id' => $user_id,
                'accepted_at' => current_time('mysql'),
                'updated_at' => current_time('mysql')
            ],
            ['id' => $invitation->id]
        );
        // Set user role to artist with can_publish=false (needs verification)
        $user = get_userdata($user_id);
        // Set the user's verification status
        $user->add_cap('skip_moderation', true);
        // If there's a shop to add the artist to, do that now
        if (!empty($invitation->to_shop)) {
            JVB()->routes('shopInvite')->addArtistToShop($user_id, $invitation->to_shop);
        }
        // Notify inviters
        $this->notifyInvitersOfAcceptance($invitation, $user_id);
        return true;
    }
    /**
     * Notify all inviters that the invitation was accepted
     * @param object $invitation The invitation object
     * @param int $user_id the newly added user id
     * @return void
     */
    protected function notifyInvitersOfAcceptance(object $invitation, int $user_id):void
    {
        $inviters = json_decode($invitation->inviters, true);
        $user_data = get_userdata($user_id);
        foreach ($inviters as $inviter) {
            JVB()->notification()->addNotification(
                $inviter['user_id'],
                'artist_joined',
                [
                    'invited_email' => $invitation->email,
                    'user_id' => $user_id,
                    'display_name' => $user_data->display_name
                ]
            );
        }
    }
    /**
     * Check if a registered user has a pending invitation. Accept invitation if so
     * @param int $user_id The user ID to check
     * @return void
     */
    public function checkInvitation(int $user_id):void
    {
        $user = get_userdata($user_id);
        if (!$user) {
            return;
        }
        // Check if there's a token and email in the request
        $token = isset($_GET['invite']) ? sanitize_text_field($_GET['invite']) : '';
        $email = isset($_GET['email']) ? sanitize_email($_GET['email']) : '';
        if ($token && $email && $email === $user->user_email) {
            $this->acceptInvitation($token, $email, $user_id);
        }
    }
    /**
     * Clean up expired invitations
     * @return void
     */
    public function cleanupExpiredInvitations():void
    {
        global $wpdb;
        $wpdb->query($wpdb->prepare(
            "UPDATE {$this->tableName}
            SET status = 'expired', updated_at = %s
            WHERE status = 'pending' AND expires_at < NOW()",
            current_time('mysql')
        ));
    }
    /**
     * Get invitations sent by a specific user
     * @param array $args built by buildParams()
     * @return WP_REST_Response
     */
    public function getUserInvitations(array $args):WP_REST_Response
    {
        if (!$this->checkUser($args['user'])) {
            return new WP_REST_Response([
                'success'   => false,
                'message'   => 'Invalid user'
            ]);
        }
        $key = $this->cache->generateKey($args);
        $cache = $this->cache->get($key);
        if ($cache) {
            return new WP_REST_Response($cache);
        }
        $per_page = 20;
        // Build query conditions
        $conditions = [];
        $params = [];
        $conditions[] = "inviters LIKE %s";
        $params[] = '%"'.$args['user'].'"%';
        // Filter by status
        if ($args['status'] !== 'all') {
            $conditions[] = "status = %s";
            $params[] = $args['status'];
        }
        $where = !empty($conditions) ? "WHERE " . implode(' AND ', $conditions) : "";
        // Count total invitations for pagination
        $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}";
        $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params));
        // Get paginated invitations
        $offset = ($args['page'] - 1) * $per_page;
        $query = "SELECT * FROM {$this->tableNames[$args['role']]} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d";
        // Add pagination parameters
        $pagination_params = array_merge($params, [$per_page, $offset]);
        $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination_params));
        // Format invitations for response
        $formatted = [];
        foreach ($invitations as $invitation) {
            $formatted[] = $this->formatInvitation($invitation);
        }
        $return = [
            'invitations' => $formatted,
            'total' => (int)$total,
            'pages' => ceil($total / $per_page),
            'page' => $args['page'],
            'per_page' => $per_page
        ];
        $this->cache->set($key, $return);
        return new WP_REST_Response($return);
    }
    /**
     * Get a specific invitation by its ID
     *
     * @param int $invitationID The invitation ID to fetch
     * @param string $role
     * @return array|WP_Error The formatted invitation or an error
     */
    protected function getInvitation(int $invitationID, string $role):array|WP_Error
    {
        // Validate invitation ID
        $invitationID = intval($invitationID);
        if (!$invitationID) {
            return new WP_Error('invalid_id', 'Invalid invitation ID');
        }
        // Try to get from cache first
        $cached = $this->cache->get($invitationID);
        if ($cached) {
            return $cached;
        }
        // Query the database
        $invitation = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->tableNames[$role]} WHERE id = %d",
            $invitationID
        ));
        // Return error if not found
        if (!$invitation) {
            return new WP_Error('not_found', 'Invitation not found');
        }
        // Format the invitation for response
        $formatted = $this->formatInvitation($invitation);
        // Cache the result
        $this->cache->set($invitationID, $formatted);
        return $formatted;
    }
    /**
     * Get invitations for a specific user by their email or user ID
     *
     * @param int|string $identifier Either user ID or email of the invited person
     * @param bool $include_token Whether to include the token in the response
     * @return array|WP_Error The formatted invitations or an error
     */
    public function getInvitationByUser(int|string $identifier):array|WP_Error
    {
        // Try to get from cache first
        $cached = $this->cache->get($identifier);
        if ($cached) {
            return $cached;
        }
        global $wpdb;
        // Determine if we have a user ID or email
        if (is_numeric($identifier)) {
            // We have a user ID
            $userID = intval($identifier);
            // Query by user ID
            $invitation = $wpdb->get_row($wpdb->prepare(
                "SELECT * FROM {$this->tableName} WHERE new_user_id = %d",
                $userID
            ));
        } else {
            // We have an email
            $email = sanitize_email($identifier);
            if (!is_email($email)) {
                return new WP_Error('invalid_email', 'Invalid email address');
            }
            // Query by email
            $invitation = $wpdb->get_row($wpdb->prepare(
                "SELECT * FROM {$this->tableName} WHERE email = %s",
                $email
            ));
        }
        // Return error if not found
        if (!$invitation) {
            return new WP_Error('not_found', 'No invitations found for this user');
        }
        // Format the invitation for response
        $formattedInvitation = $this->formatInvitation($invitation);
        $this->cache->set($identifier, $formattedInvitation);
        return $formattedInvitation;
    }
    /**
     * Format invitation for API response
     * @param object $invitation The invitation object
     * @param bool $include_token whether or not to include the token in response
     * @return array The formatted invitation
     */
    protected function formatInvitation(object $invitation, bool $include_token = false):array
    {
        // Parse inviters JSON
        $inviters = json_decode($invitation->inviters ?? '[]', true) ?: [];
        // Format inviters with names
        $inviter_details = [];
        foreach ($inviters as $inviter_id) {
            $inviter_details[] = [
                'id' => $inviter_id,
                'name' => jvbGetUsername($inviter_id)
            ];
        }
        // Build formatted invitation
        $formatted = [
            'id' => $invitation->id,
            'name'  => $invitation->name,
            'email' => $invitation->email,
            'status' => $invitation->status,
            'expires_at' => $invitation->expires_at,
            'accepted_at' => $invitation->accepted_at,
            'created_at' => $invitation->created_at,
            'updated_at' => $invitation->updated_at,
            'inviters' => $inviters
        ];
        // Include shop if assigned
        if (!empty($invitation->to_shop)) {
            $shop = get_term($invitation->to_shop, BASE . 'shop');
            if ($shop && !is_wp_error($shop)) {
                $formatted['shop'] = [
                    'id' => $shop->term_id,
                    'name' => $shop->name
                ];
            }
        }
        // Include token if needed (only for validation)
        if ($include_token) {
            $formatted['token'] = $invitation->invitation_token;
        }
        // Add registration URL for convenience
        $formatted['registration_url'] = add_query_arg([
            'token' => $invitation->invitation_token,
            'email' => urlencode($invitation->email)
        ], home_url('/register/'));
        return $formatted;
    }
    /**
     * @param WP_Error|array $result The WP_Error to replace, if this is the operation type we're looking for
     * @param object $operation The operation object
     * @param array $data The data to process
     * @return WP_Error|array WP_Error or array of processed data
     *
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data):array|WP_Error
    {
        switch ($operation->type) {
            case 'invitation_create':
                return $this->processInvitations($data, $operation->user_id);
            case 'invitation_revoke':
                return $this->revokeInvite(
                    $data['invited']
                );
        }
        return $result;
    }
    /**
     * Process a batch of invitations with transaction support
     *
     * @param array $data Array of invitation data ['role' => $invites ]
     * @param int $user_id User ID of the inviter
     * @return array Result data with success/failure information
     */
    public function processInvitations(array $data, int $user_id):array
    {
        if (!$this->checkUser($user_id)) {
            return [
                'success'   => false,
                'result'   => 'Invalid User',
            ];
        }
        // Start transaction
        $this->wpdb->query('START TRANSACTION');
        $results = [
            'success' => [],
            'failed' => []
        ];
        try {
            foreach ($data as $role => $invitations) {
                foreach ($invitations as $invite) {
                    if (!$invite['invited_name'] || !$invite['invited_email']) {
                        $results['failed'][] = [
                            'email' => $invite['invited_email'],
                            'name' => $invite['invited_name'],
                            'reason' => 'Invalid name or email'
                        ];
                        continue;
                    }
                    if ($invite['to_term'] && !$this->checkTerm($invite)) {
                        $results['failed'][] = [
                            'email' => $invite['invited_email'],
                            'name'  => $invite['invited_name'],
                            'reason'    => 'Invalid taxonomy to add to'
                        ];
                    }
                    // Create invitation (modify your existing method to avoid sending emails yet)
                    $result = $this->createInvitation($invite['invited_name'], $invite['invited_email'], $user_id, $role, $invite['to_term'], $invite['taxonomy'], false);
                    if (is_wp_error($result)) {
                        $results['failed'][] = [
                            'email' => $invite['invited_email'],
                            'name' => $invite['invited_name'],
                            'reason' => $result->get_error_message()
                        ];
                    } else {
                        $results['success'][] = [
                            'email' => $invite['invited_email'],
                            'name' => $invite['invited_name'],
                            'id' => $result['id'],
                     'to_term'   => $invite['to_term'],
                     'taxonomy'  => $invite['taxonomy'],
                     'role'      => $role,
                            'expires_at' => $result['expires_at']
                        ];
                    }
                }
            }
            // If we've processed at least one invitation successfully, commit
            if (!empty($results['success'])) {
                $this->wpdb->query('COMMIT');
                // Now send emails for successful invitations
                foreach ($results['success'] as $invitation) {
                    $this->sendInvitationEmail(
                        $invitation['name'],
                        $invitation['email'],
                        $invitation['token'],
                        $user_id,
                        [$invitation['taxonomy'] => $invitation['to_term']],
                  $invitation['role']
                    );
                }
            } else {
                // No successful invitations, roll back
                $this->wpdb->query('ROLLBACK');
            }
            return [
            'success'   => count($results['success']) > count($results['failed']),
            'results'   => $results
         ];
        } catch (Exception $e) {
            // Handle error and roll back transaction
            $this->wpdb->query('ROLLBACK');
            JVB()->error()->log(
                'invitation_create',
                'Error processing batch invitations: ' . $e->getMessage(),
                [
                    'user_id' => $user_id,
                    'error' => $e->getMessage()
                ],
                'error'
            );
            return [
                'success' => false,
                'result'   => [
               'failed' => $invitations,
                  'error' => $e->getMessage()
            ]
            ];
        }
    }
   public function modifyLoginLabels(array $labels, array $get_params): array
   {
      // Only modify if invitation params present
      if (!array_key_exists('invite', $get_params) || !array_key_exists('email', $get_params)) {
         return $labels;
      }
      $email = sanitize_email($get_params['email']);
      $token = sanitize_text_field($get_params['invite']);
      $user = email_exists($email);
      if (!$user) {
         return $labels;
      }
      $role = jvbUserRole($user);
      // Get invitation data
      $data = $this->verifyInvitation(
         $token,
         $email,
         $role,
      );
      if (!$data) {
         return $labels;
      }
      // Build custom message
      $inviters = json_decode($data->inviters, true);
      $name = $data->name;
      $names = array_map(function($inviter) {
         $artist = jvbContentFromUser((int)$inviter['user_id']);
         return $artist['name'] ?: $artist['display_name'];
      }, $inviters);
      $message = count($names) > 1
         ? 'are already here, and have invited you to join in!'
         : ' is already here, and invited you to join in!';
      // Modify labels
      $labels['title'] = 'Join the Scene, ' . $data->name;
      $labels['description'] = [jvbCommaList($names) . ' ' . $message];
      return $labels;
      return $formatted;
   }
}