setInviteConfig(); $this->cache = Cache::for('invitations'); $this->table = CustomTable::for('invitations'); add_action('init', [$this, 'registerInvitationExecutors'], 5); add_action('user_register', [$this, 'checkInvitation']); add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2); add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']); add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } protected function setInviteConfig():void { $this->inviteConfig = get_option(BASE.'invitation_config', [ 'roles' => [], 'terms' => [] ]); } public function invitableTerms():array { return $this->inviteConfig['terms']; } public function invitableRoles():array { return $this->inviteConfig['roles']; } public function getInviteConfig():array { return $this->inviteConfig; } /** * Register invitation operation types with queue TypeRegistry */ public function registerInvitationExecutors(): void { $registry = JVB()->queue()->registry(); $executor = new InvitationExecutor(); // Create invitations - chunked at 20 $registry->register('invitation_create', new TypeConfig( executor: $executor, chunkKey: 'invitations', chunkSize: 20 )); // Resend invitations - chunked at 10 $registry->register('invitation_resend', new TypeConfig( executor: $executor, chunkKey: 'invitations', chunkSize: 10 )); // Revoke invitations $registry->register('invitation_revoke', new TypeConfig( executor: $executor )); } /** * Build invite types from JVB constants * Combines JVB_MEMBERSHIP['can_invite'] and invitable taxonomies */ protected function buildInviteTypes(): array { $invitable = $this->cache->remember( 'invitableTypes', function () { $types = []; // Global invitations from JVB_MEMBERSHIP if (!empty(JVB_MEMBERSHIP['can_invite'])) { foreach (JVB_MEMBERSHIP['can_invite'] as $role => $canInvite) { $types[$role] = [ 'can_invite' => $canInvite, 'to_terms' => [] ]; } } // Term invitations from invitable content taxonomies $invitable = Registrar::getFeatured('invitable', 'term'); $content = Registrar::getFeatured('is_content', 'term'); $ownable = Registrar::getFeatured('is_ownable', 'term'); $taxonomies = array_intersect($invitable, $content, $ownable); if (!empty($taxonomies)) { $users = Registrar::getRegistered('user'); } foreach ($taxonomies as $taxonomy) { $registrar = Registrar::getInstance($taxonomy); foreach ($registrar->registrar->for as $content) { // Find which user roles can create this content foreach ($users as $user) { $userRegistrar = Registrar::getInstance($user); $creatable = $userRegistrar->getCreatable(); if (in_array($content, $creatable)) { if (!isset($types[$role])) { $types[$role] = [ 'can_invite' => [], 'to_terms' => [] ]; } if (!in_array($taxonomy, $types[$role]['to_terms'])) { $types[$role]['to_terms'][] = $taxonomy; } } } } } return $types; } ); return $invitable; } /****************************************************************** * UTILITY ******************************************************************/ public function canInviteToTerm(int $userID, string $taxonomy, int $termID):bool { $taxonomy = jvbNoBase($taxonomy); // Check if taxonomy is invitable if (!in_array($taxonomy, $this->invitableTerms())) { return false; } // User must be owner or manager of the term return JVB()->roles()->isManager($userID, $termID); } /** * Check if user can send global invitations */ public function canInviteGlobally(int $userID, string $targetRole): bool { $userRole = jvbUserRole($userID); $allowedRoles = $this->inviteConfig['roles'][$userRole] ?? []; return in_array($targetRole, $allowedRoles); } public function validateInvitations(int $userID, array $invites): array { $validated = []; foreach ($invites as $invite) { $sanitized = $this->sanitizeInvitation($invite); if (!$sanitized) { continue; } // Check permissions if ($sanitized['to_term'] && $sanitized['taxonomy']) { // Term invitation - check management capability if (!$this->canInviteToTerm($userID, $sanitized['taxonomy'], $sanitized['to_term'])) { continue; } } else { // Global invitation - check if allowed to invite this role if (!$this->canInviteGlobally($userID, $sanitized['invited_role'])) { continue; } } $validated[] = $sanitized; } return $validated; } /** * Sanitize single invitation */ protected function sanitizeInvitation(array $invite): ?array { $email = sanitize_email($invite['email'] ?? ''); $name = sanitize_text_field($invite['name'] ?? ''); $role = $invite['role'] ?? ''; if (!is_email($email) || empty($name) || empty($role)) { return null; } // Check if user already exists if (email_exists($email)) { return null; } $sanitized = [ 'email' => $email, 'name' => $name, 'invited_role' => $role, 'to_term' => isset($invite['to_term']) ? (int) $invite['to_term'] : null, 'taxonomy' => isset($invite['taxonomy']) ? jvbNoBase($invite['taxonomy']) : null ]; // Validate term if provided if ($sanitized['to_term'] && $sanitized['taxonomy']) { $term = get_term($sanitized['to_term'], BASE . $sanitized['taxonomy']); if (!$term || is_wp_error($term)) { $sanitized['to_term'] = null; $sanitized['taxonomy'] = null; } } return $sanitized; } /************************************************************************** * QUEUE **************************************************************************/ /** * Process bulk invitations (called by queue) */ public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error { if ($operation->type !== 'invitation_create') { return $result; } return $this->processInvitations($data, $operation->user_id); } /** * Process invitations with transaction support */ protected function processInvitations(array $data, int $userID): array { $invitations = $data['invitations'] ?? []; $results = [ 'success' => [], 'failed' => [] ]; $this->table->startTransaction(); try { foreach ($invitations as $invite) { $result = $this->createInvitation( $invite['name'], $invite['email'], $userID, $invite['invited_role'], $invite['to_term'], $invite['taxonomy'], false // Don't send email yet ); if (is_wp_error($result)) { $results['failed'][] = [ 'email' => $invite['email'], 'name' => $invite['name'], 'reason' => $result->get_error_message() ]; } else { $results['success'][] = array_merge($result, [ 'email' => $invite['email'], 'name' => $invite['name'] ]); } } if (!empty($results['success'])) { $this->table->commit(); // Send emails foreach ($results['success'] as $invitation) { $terms = []; if ($invitation['to_term'] && $invitation['taxonomy']) { $terms[$invitation['taxonomy']] = $invitation['to_term']; } $this->sendInvitationEmail( $invitation['name'], $invitation['email'], $invitation['token'], $userID, $terms, $invitation['invited_role'] ); } } else { $this->table->rollback(); } return [ 'success' => count($results['success']) > 0, 'results' => $results ]; } catch (Exception $e) { $this->table->rollback(); JVB()->error()->log( 'invitation_create', 'Error processing invitations: ' . $e->getMessage(), ['user_id' => $userID], ); return [ 'success' => false, 'result' => [ 'failed' => $invitations, 'error' => $e->getMessage() ] ]; } } /** * Create or update an invitation */ public function createInvitation( string $name, string $email, int $inviterID, string $invitedRole, ?int $termID = null, ?string $taxonomy = null, bool $sendEmail = true ): WP_Error|array { $email = sanitize_email($email); if (!is_email($email)) { return new WP_Error('invalid_email', 'Invalid email address'); } if (email_exists($email)) { return new WP_Error('user_exists', 'User already registered'); } // Check for existing invitation $existing = $this->table->get([ 'email' => $email, 'invited_role' => $invitedRole ]); $token = wp_generate_password(32, false); $expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days")); if ($existing) { // Update existing $inviters = json_decode($existing->inviters, true) ?: []; $inviterExists = false; foreach ($inviters as &$inviter) { if ($inviter['user_id'] == $inviterID) { $inviterExists = true; $inviter['invited_at'] = current_time('mysql'); break; } } if (!$inviterExists) { $inviters[] = [ 'user_id' => $inviterID, 'invited_at' => current_time('mysql') ]; } $updateData = [ 'inviters' => json_encode($inviters), 'status' => 'pending', 'expires_at' => $expiresAt ]; if ($termID && $taxonomy) { $updateData['to_' . $taxonomy] = $termID; } if ($existing->status === 'expired') { $updateData['invitation_token'] = $token; } else { $token = $existing->invitation_token; } $this->table->update($updateData, ['id' => $existing->id]); $invitationID = $existing->id; } else { // Create new $insertData = [ 'name' => sanitize_text_field($name), 'email' => $email, 'invitation_token' => $token, 'invited_role' => $invitedRole, 'status' => 'pending', 'inviters' => json_encode([[ 'user_id' => $inviterID, 'invited_at' => current_time('mysql') ]]), 'expires_at' => $expiresAt ]; if ($termID && $taxonomy) { $insertData['to_' . $taxonomy] = $termID; } $invitationID = $this->table->insert($insertData); } if ($sendEmail) { $terms = []; if ($termID && $taxonomy) { $terms[$taxonomy] = $termID; } $this->sendInvitationEmail($name, $email, $token, $inviterID, $terms, $invitedRole); } return [ 'id' => $invitationID, 'token' => $token, 'expires_at' => $expiresAt, 'to_term' => $termID, 'taxonomy' => $taxonomy, 'invited_role' => $invitedRole ]; } /** * Send invitation email */ public function sendInvitationEmail( string $name, string $email, string $token, int $inviterID, array $terms, string $role ): void { $inviterName = jvbGetUsername($inviterID); $siteName = get_bloginfo('name'); $subject = apply_filters('jvbInvitationSubject', sprintf("%s invited you to join %s!", $inviterName, $siteName), $inviterName ); $signupUrl = add_query_arg([ 'invite' => $token, 'email' => urlencode($email), 'name' => urlencode($name), 'role' => $role ], wp_registration_url()); // Build term-specific content $termContent = []; foreach ($terms as $taxonomy => $termID) { if (!$termID) continue; $term = get_term($termID, BASE . $taxonomy); if ($term && !is_wp_error($term)) { $registrar = Registrar::getInstance($taxonomy); $singular = $registrar ? $registrar->getSingular() : $taxonomy; $termContent[] = sprintf( "

%s has also invited you to join %s. You'll be automatically added to this %s when you register.

", $inviterName, html_entity_decode($term->name), $singular ); } } $termText = implode('', $termContent); $button = JVB()->email()->button($signupUrl, 'Join the Scene!'); $link = JVB()->email()->link($signupUrl); $signature = JVB()->email()->signature(); $message = sprintf( '

Hi %s!

%s has invited you to join them on %s.

%s

Interested?

Join in by clicking the button below:

%s

Or by copying and pasting the link below into your browser:

%s

This invitation expires in %d days.

Ink on,

%s', $name, $inviterName, $siteName, $termText, $button, $link, $this->expiryDays, $signature ); $message = apply_filters('jvbInvitationMessage', $message, $name, $inviterName, $role, $terms, $this->expiryDays, $button, $link, $signature ); JVB()->email()->sendEmail($email, $subject, $message); } /** * Check invitation on user registration * @throws Exception */ public function checkInvitation(int $userID): void { $token = sanitize_text_field($_GET['invite'] ?? ''); $email = sanitize_email($_GET['email'] ?? ''); if (!$token || !$email) { return; } $user = get_userdata($userID); if (!$user || $user->user_email !== $email) { return; } $this->acceptInvitation($token, $email, $userID); } /** * Accept invitation and grant appropriate capabilities * @throws Exception */ public function acceptInvitation(string $token, string $email, int $userID): bool { // Verify invitation using fluent CustomTable $invitation = $this->table ->where([ 'invitation_token' => $token, 'email' => $email, 'status' => 'pending' ]) ->first(); if (!$invitation) { return false; } // Check expiry if (strtotime($invitation->expires_at) < time()) { return false; } // Update in transaction $success = $this->table->transaction(function($table) use ($invitation, $userID) { // Update invitation status $table->update( [ 'status' => 'accepted', 'new_user_id' => $userID, 'accepted_at' => current_time('mysql') ], ['id' => $invitation->id] ); // Grant capabilities $user = get_userdata($userID); $user->add_cap('skip_moderation', true); return true; }); if (!$success) { return false; } // Handle term membership $this->processTermMembership($userID, $invitation); // Notify inviters $this->notifyInvitersOfAcceptance($invitation, $userID); // Invalidate cache Cache::for('invitations')->forget('user_' . $userID); return true; } /** * Process term membership for accepted invitation */ protected function processTermMembership(int $userID, object $invitation): void { foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) { $column = 'to_' . $taxonomy; if (isset($invitation->$column) && $invitation->$column) { $termID = (int) $invitation->$column; do_action( BASE . 'add_user_to_term', $userID, $termID, $taxonomy, 'member' ); } } } /** * Notify inviters that invitation was accepted */ protected function notifyInvitersOfAcceptance(object $invitation, int $userID): void { $inviters = json_decode($invitation->inviters, true) ?: []; $userData = get_userdata($userID); foreach ($inviters as $inviter) { JVB()->notification()->addNotification( $inviter['user_id'], 'invitation_accepted', [ 'invited_email' => $invitation->email, 'user_id' => $userID, 'display_name' => $userData->display_name ] ); } } /** * Clean up expired invitations (daily cron) */ public function cleanupExpiredInvitations(): void { // Use raw query for date comparison $this->table->query( "UPDATE {$this->table->getFullTableName()} SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW()" ); // Clear cache after cleanup Cache::for('invitations')->flush(); } /***************************************************************** * Emails *****************************************************************/ /** * Send revocation email */ public function sendRevocationEmail(string $email, string $name): void { $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 or the site admin.

', $name, $siteName ), $name ); JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED'); } /** * TODO: Check with LoginManager.php * Modify login labels for invitation flow */ public function modifyLoginLabels(array $labels, array $getParams): array { if (!isset($getParams['invite']) || !isset($getParams['email'])) { return $labels; } $email = sanitize_email($getParams['email']); $token = sanitize_text_field($getParams['invite']); $role = $getParams['role'] ?? ''; if (empty($role)) { return $labels; } // Use fluent interface $invitation = $this->table ->where([ 'invitation_token' => $token, 'email' => $email, 'invited_role' => $role, 'status' => 'pending' ]) ->first(); if (!$invitation) { return $labels; } // Build inviter names $inviters = json_decode($invitation->inviters, true) ?: []; $names = array_map(fn($inviter) => jvbGetUsername($inviter['user_id']), $inviters); $message = count($names) > 1 ? 'are already here, and have invited you to join in!' : ' is already here, and invited you to join in!'; $labels['title'] = 'Join the Scene, ' . $invitation->name; $labels['description'] = [jvbCommaList($names) . ' ' . $message]; return $labels; } }