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 */ class Invitations extends RestRouteManager { protected string $tableName; protected array $inviteTypes; protected $wpdb; protected string $prefix; protected array $tableNames; protected int $expiryDays = 14; // Invitations expire after 14 days public function __construct() { $this->cache_name = 'invitations'; parent::__construct(); global $wpdb; $this->inviteTypes = jvbInviteTableTypes(); $this->tableNames = jvbInviteTables(); $this->wpdb = $wpdb; $this->prefix = $wpdb->prefix; // Add hooks for processing accepted invitations add_action('user_register', [$this, 'checkInvitation'], 10, 1); 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; } /** * 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'); } // 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'); } 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'); } } 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'); } $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 )); if (!$invitation) { return new WP_Error('invalid_invitation', 'Invalid invitation token or email'); } // Check if expired if (strtotime($invitation->expires_at) < time()) { return new WP_Error('expired_invitation', 'This invitation has expired'); } return $invitation; } /** * 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; $siteName = get_bloginfo('name'); $subject = apply_filters('jvbInvitationSubject', sprintf( "%s invited you to join %s!", $inviter_name, $siteName ), $inviter_name ); $signup_url = add_query_arg([ 'invite' => $token, 'email' => urlencode($email), 'name' => $name, 'role' => $role ], wp_registration_url()); // Get shop name if applicable $toContentTax = []; if (!empty ($terms)) { foreach ($terms as $taxonomy => $termID) { $term = get_term($termID, BASE . $taxonomy); if ($term && !is_wp_error($term)) { $toContentTax[] = sprintf( "
%s has also invited you to join %s. You'll be automatically added to this %s when you register.
", $inviter_name, html_entity_decode($term->name), $taxonomy ); } } } $toContentTax = implode(' ', $toContentTax); $button = JVB()->email()->button($signup_url, 'Join the Scene!'); $link = JVB()->email()->link($signup_url); $signature = JVB()->email()->signature(); $message = sprintf( 'Hi %s!
%s has invited you to join them on %s.
Join in by clicking the button below:
%sOr by copying and pasting the link below into your browser:
%s %sThis invitation expires in %d days.
Ink on, %s
%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( 'Hey %s,
This is to let you know that your invitation to join %s has been revoked.
If you believe this was done in error, please contact the person who invited you, the site admin, or try registering yourself.
', $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; } }