cache_name = 'approvals'; $this->hasMemberApproval = Features::forMembership()->has('member_verified'); parent::__construct(); $this->initTypes(); if ($this->hasMemberApproval) { add_action('user_register', [$this, 'handleNewUserRegistration'], 10, 2); } add_action('jvb_cleanup_expired_approvals', [$this, 'cleanupExpiredApprovals']); } protected function initTypes():void { $approvals = jvbApprovalTypes(); $this->userTypes = []; $this->termTypes = []; if ($this->hasMemberApproval) { $this->userTypes = array_filter( array_keys($approvals), function ($item) { return $item !== 'term'; } ); $this->allTypes = $this->userTypes; } if (jvbSiteHasTermApproval()) { $this->termTypes = $approvals['term']??[]; $this->allTypes[] = 'term'; } } public function registerRoutes():void { register_rest_route($this->namespace, '/approvals', [ [ 'methods' => 'GET', 'callback' => [ $this, 'getApprovals' ], 'permission_callback' => [ $this, 'checkPermission' ] ], [ 'methods' => 'POST', 'callback' => [ $this, 'handleApprovalAction' ], 'permission_callback' => [ $this, 'checkPermission' ] ] ]); } /** * @param WP_REST_Request $request The REST request * * @return bool */ public function checkPermission(WP_REST_Request $request):bool { $userID = get_current_user_id(); if (!user_can($userID, 'skip_moderation')) { return false; } return parent::checkPermission($request); } /** * Handler for user registration * * @param int $user_id New user ID * @param object $user the new user object * * @return void */ public function handleNewUserRegistration(int $user_id, object $user):void { $intersect = array_intersect( array_map( function ($role) { return BASE.$role; }, $this->userTypes ), (array) $user->roles ); if (!empty($intersect)) { // Mark as unverified initially $user->add_cap('skip_moderation', false); // Create approval request $this->createArtistApprovalRequest($user_id); } } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleApprovalAction(WP_REST_Request $request):WP_REST_Response { $data = $request->get_params(); $request_id = $data['request_id'] ?? 0; $user_id = (array_key_exists('user', $data) && is_numeric($data['user'])) ? (int) $data['user'] : get_current_user_id(); $action = (array_key_exists('action', $data) && in_array($data['action'], [ 'approve', 'reject' ])) ? $data['action'] : false; $type = (array_key_exists('type', $data) && in_array($data['type'], $this->allTypes)) ? $data['type'] : false; $notes = (array_key_exists('notes', $data)) ? sanitize_text_field($data['notes']) : ''; if ($action && $request_id !== 0 && $type) { $result = $this->handleVote($type, $action, $request_id, $user_id, $notes); return new WP_REST_Response([ 'success' => $result, 'message' => $result ? 'Vote recorded successfully' : 'Failed to record vote' ], $result ? 200 : 500); } return new WP_REST_Response([ 'success' => false, 'message' => 'Invalid action or request ID' ], 400); } protected function getRequestTable(string $type, string $prefix):string { return match ($type) { 'term' => $prefix . BASE . 'approval_term_requests', default => $prefix . BASE . 'approval_' . $type . '_requests', }; } protected function getVoteTable(string $type, string $prefix):string { return match ($type) { 'term' => $prefix . BASE . 'approval_term_votes', default => $prefix . BASE . 'approval_' . $type . '_votes', }; } /** * Artist and Term Approvals */ protected function handleVote(string $type, string $vote, int $request_id, int $user_id, string $notes = ''):bool { if (!in_array($vote, ['approve', 'reject'])) { return false; } global $wpdb; $table = $this->getRequestTable($type, $wpdb->prefix); $votes = $this->getVoteTable($type, $wpdb->prefix); try { $request = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $request_id )); if (!$request || $request->status !== 'pending') { throw new Exception("Invalid approval request"); } $already_voted = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $votes WHERE request_id = %d AND user_id = %d", $request_id, $user_id )); if ($already_voted && $already_voted->vote !== $vote) { $wpdb->update( $votes, [ 'vote' => $vote, ], [ 'id' => $already_voted->id ] ); return true; } elseif ($already_voted) { throw new Exception("User has already voted on this request"); } $result = $wpdb->insert( $votes, [ 'request_id' => $request_id, 'user_id' => $user_id, 'vote' => $vote, 'notes' => $notes, 'created_at' => current_time('mysql') ] ); if (!$result) { throw new Exception("Failed to record vote"); } $user = get_userdata($user_id); if ($vote === 'approve') { $approvers = json_decode($request->approved_by, true)?:[]; $approvers[$user_id] = [ 'name' => $user->display_name, 'voted' => current_time('mysql') ]; $wpdb->update( $table, [ 'current_approvals' => $request->current_approvals + 1, 'updated_at' => current_time('mysql'), 'approved_by' => $approvers, 'expires_at' => $this->rebuildExpiryDate() ], [ 'id' => $request_id ] ); if ($request->current_approvals + 1 >= $request->required_approvals) { switch ($type) { case 'user': case 'artist': $this->completeVerification($request_id); break; case 'term': $this->makeTermLive($request); break; } } } elseif ($vote === 'reject') { $rejecters = json_decode($request->rejected_by, true)?:[]; $rejecters[$user_id] = [ 'name' => $user->display_name, 'voted' => current_time('mysql') ]; $wpdb->update( $table, [ 'current_rejections' => $request->current_rejections + 1, 'rejected_by' => $rejecters, 'updated_at' => current_time('mysql'), 'expires_at' => $this->rebuildExpiryDate() ], [ 'id' => $request_id ] ); if ($request->current_rejections + 1 >= $request->required_approvals) { switch ($type) { case 'user': case 'artist': $this->denyVerification($request_id); break; case 'term': $this->makeTermUnalive($request); break; } } } $wpdb->query('COMMIT'); return true; } catch (Exception $e) { $wpdb->query('ROLLBACK'); JVB()->error() ->log( '[ApprovalRoutes]:handleVote', "Error creating '.$type.' approval request: " . $e->getMessage(), [ 'user_id' => $user_id, 'request_id' => $request_id, 'vote' => $vote ] ); return false; } } protected function rebuildExpiryDate() { return date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days", time())); } /** * @param string $type user/artist or term * @param array $request * * @return bool|int */ protected function createApprovalRequest(string $type, array $request):bool|int { global $wpdb; $table = $this->getRequestTable($type, $wpdb->prefix); $result = $wpdb->insert( $table, $request ); if (!$result) { throw new Exception($wpdb->last_error); } return $wpdb->insert_id; } /************* * Artist Approvals ************/ /** * Create artist approval request * * @param int $user_id User ID to be approved * * @return int|false Request ID or false on failure */ public function createArtistApprovalRequest(int $user_id):int|false { global $wpdb; $wpdb->query('START TRANSACTION'); try { //Check for existing first $table = $this->getRequestTable(jvbUserRole($user_id), $wpdb->prefix); // Verify this is not a duplicate request $existing = $wpdb->get_var($wpdb->prepare( "SELECT id FROM $table WHERE user_id = %d", $user_id )); if ($existing) { return $existing; } $user_data = get_userdata($user_id); $request = [ 'user_id' => $user_id, 'status' => 'pending', 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')), 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), 'name' => $user_data->display_name, 'email' => $user_data->user_email, ]; $result = $this->createApprovalRequest('user', $request); if (!$result) { throw new Exception($wpdb->last_error); } $wpdb->query('COMMIT'); return $result; } catch (Exception $e) { $wpdb->query('ROLLBACK'); JVB()->error() ->log( '[ApprovalRoutes]:createArtistApprovalRequest', "Error creating artist approval request: " . $e->getMessage(), [ 'user_id' => $user_id, ] ); return false; } } /** * Mark an artist as verified * * @param int $user_id The user to verify * @param int $verified_by ID of user who verified them (optional) * * @return bool Success status */ public function verifyArtist(int $user_id, int $verified_by = 0):bool { $user = get_userdata($user_id); // Check if user has the artist role if (!array_intersect(array_map(function ($role) { return BASE.$role; }, $this->userTypes), $user->roles)) { return false; } // Add the capability $user->add_cap('skip_moderation', true); // Store verification metadata update_user_meta($user_id, BASE . 'verification_date', current_time('mysql')); if ($verified_by) { update_user_meta($user_id, BASE . 'verified_by', $verified_by); } return true; } /** * Mark an artist as verified * * @param int $user_id The user to verify * @param int $verified_by ID of user who verified them (optional) * * @return bool Success status */ public function unverifyArtist(int $user_id, int $verified_by = 0):bool { $user = get_userdata($user_id); // Check if user has the artist role if (!array_intersect(array_map(function ($role) { return BASE.$role; }, $this->userTypes), $user->roles)) { return false; } // Add the capability $user->add_cap('skip_moderation', false); // Store verification metadata update_user_meta($user_id, BASE . 'unverification_date', current_time('mysql')); if ($verified_by) { update_user_meta($user_id, BASE . 'unverified_by', $verified_by); } return true; } /** * Record an approval vote for an artist * * @param int $user_id User casting the approval vote * @param int $request_id The approval request ID * @param string $vote 'approve' or 'reject' * @param string $notes Optional notes for the vote * * @return bool Success status */ public function voteForArtist(int $user_id, int $request_id, string $vote, string $notes = ''):bool { return $this->handleVote(jvbUserRole($user_id), $vote, $request_id, $user_id, $notes); } /** * Mark an artist as verified after receiving required approvals * * @param int $request_id The approval request ID * * @return bool Success status */ public function completeVerification(int $request_id):bool { global $wpdb; $approval_table = $wpdb->prefix . $this->userRequests; // Get the request details $request = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $approval_table WHERE id = %d", $request_id )); if (!$request || $request->status !== 'pending') { return false; } // Check if enough approvals have been collected if ($request->current_approvals < $request->required_approvals) { return false; } // Start a transaction $wpdb->query('START TRANSACTION'); try { // Get the user ID from the request $user_id = $request->user_id; $this->verifyArtist($user_id, $request->current_approvals); // Update the request status $updated = $wpdb->update( $approval_table, [ 'status' => 'approved', 'updated_at' => current_time('mysql') ], [ 'id' => $request_id ] ); if ($updated === false) { throw new Exception("Failed to update approval request status"); } // Notify the user they've been verified JVB()->notification()->addNotification( $user_id, 'artist_approved', [ 'request_id' => $request_id, 'approval_date' => current_time('mysql') ] ); $wpdb->query('COMMIT'); return true; } catch (Exception $e) { $wpdb->query('ROLLBACK'); JVB()->error() ->log( '[ApprovalRoutes]:completeVerification', "Error verifying user: " . $e->getMessage(), [ 'user_id' => $user_id, ] ); return false; } } public function denyVerification(int $request_id):bool { global $wpdb; $approval_table = $wpdb->prefix . $this->userRequests; // Get the request details $request = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $approval_table WHERE id = %d", $request_id )); if (!$request || $request->status !== 'pending') { return false; } // Check if enough approvals have been collected if ($request->current_rejections < $request->required_approvals) { return false; } // Start a transaction $wpdb->query('START TRANSACTION'); try { // Get the user ID from the request $user_id = $request->user_id; $this->unverifyArtist($user_id, $request->rejected_by); // Update the request status $updated = $wpdb->update( $approval_table, [ 'status' => 'rejected', 'updated_at' => current_time('mysql') ], [ 'id' => $request_id ] ); if ($updated === false) { throw new Exception("Failed to update approval request status"); } // Notify the user they've been verified JVB()->notification()->addNotification( $user_id, 'artist_rejected', [ 'request_id' => $request_id, 'approval_date' => current_time('mysql') ] ); $wpdb->query('COMMIT'); return true; } catch (Exception $e) { $wpdb->query('ROLLBACK'); JVB()->error() ->log( '[ApprovalRoutes]:denyVerification', "Error removing artist verification status: " . $e->getMessage(), [ 'user_id' => $user_id ] ); return false; } } /** * Get verification details for a request * * @param int $requestID the request ID * @param string $type Type * * @return array|false Verification details or false if not verified */ public function getVerificationDetails(int $requestID, string $type):array|false { global $wpdb; $approval_table = $this->getRequestTable($type, $wpdb->prefix); $votes_table = $this->getVoteTable($type, $wpdb->prefix); // Get the approval request $request = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $approval_table WHERE id = %d ORDER BY updated_at DESC", $requestID ), ARRAY_A); if (!$request) { return false; } // Get the votes for this request $votes = $wpdb->get_results($wpdb->prepare( "SELECT v.*, u.display_name as approver_name FROM $votes_table v WHERE v.request_id = %d ORDER BY v.created_at", $request['id'] ), ARRAY_A); return [ 'request' => $request, 'votes' => $votes, 'verification_date' => $request['updated_at'], ]; } /************* * Term Approvals ************/ public function voteForTerm(int $user_id, int $request_id, string $vote, string $notes = ''):bool { return $this->handleVote('term', $vote, $user_id, $request_id, $notes); } /** * Publish an approved term * * @param object $request Approval request object * * @return boolean Success or failure */ protected function makeTermLive(object $request):bool { global $wpdb; try { // Get term data from request $taxonomy = $request->taxonomy; $term_name = $request->name; $parent = $request->parent; $result = wp_insert_term($term_name, $taxonomy, [ 'parent' => $parent ]); if (is_wp_error($result)) { throw new Exception($result->get_error_message()); } $term_id = $result['term_id']; $table = $this->getRequestTable('term', $wpdb); // Update request status $wpdb->update( $table, [ 'status' => 'approved', 'updated_at' => current_time('mysql'), 'created_term' => $term_id ], [ 'id' => $request->id ] ); $userIDs = []; $approvedBy = []; $approvors = json_decode($request->approved_by, true) ?: []; $requesters = json_decode($request->requested_by, true) ?: []; $rejectors = json_decode($request->rejected_by, true) ?: []; foreach (array_merge($requesters, $approvors, $rejectors) as $user_id => $info) { $userIDs[] = $user_id; } foreach ($approvors as $user_id => $info) { $approvedBy[] = $info['name']; } $approvedBy = jvbCommaList($approvedBy); // Notify the requester JVB()->notification()->addNotification( $userIDs, 'term_approved', [ 'term_id' => $term_id, 'term_name' => $term_name, 'taxonomy' => $taxonomy, 'approved_by' => $approvedBy ] ); return true; } catch (Exception $e) { JVB()->error() ->log( '[ApprovalRoutes]:makeTermLive', "Error making term live: " . $e->getMessage(), [ 'request_id' => $request->id, 'requester' => $request->requested_by, 'term_name' => $term_name, 'taxonomy' => $taxonomy ] ); return false; } } /** * Reject a proposed term * * @param object $request request object * * @return boolean Success or failure */ protected function makeTermUnalive(object $request):bool { global $wpdb; try { // Update request status $wpdb->update( $this->getRequestTable('term', $wpdb), [ 'status' => 'rejected', 'updated_at' => current_time('mysql'), ], [ 'id' => $request->id ] ); $userIDs = []; $rejectedBy = []; $approvors = json_decode($request->approved_by, true) ?: []; $requesters = json_decode($request->requested_by, true) ?: []; $rejectors = json_decode($request->rejected_by, true) ?: []; foreach (array_merge($requesters, $approvors, $rejectors) as $user_id => $info) { $userIDs[] = $user_id; } foreach ($rejectors as $user_id => $info) { $rejectedBy[] = $info['name']; } $rejectedBy = jvbCommaList($rejectedBy); // Notify the requester JVB()->notification()->addNotification( $userIDs, 'term_rejected', [ 'term_name' => $request->name, 'taxonomy' => $request->taxonomy, 'rejected_by' => $rejectedBy ] ); return true; } catch (Exception $e) { JVB()->error() ->log( '[ApprovalRoutes]:makeTermUnalive', "Error rejecting term: " . $e->getMessage(), [ 'request_id' => $request->id, 'requester' => $request->requested_by, 'term_name' => $request->name, 'taxonomy' => $request->taxonomy ] ); return false; } } /** * Create a new term approval request * * @param int $user_id User requesting approval * @param string $taxonomy Taxonomy * @param string $name New Term Name * @param int $parent Parent Term ID * @param int $required_approvals Number of approvals required * * @return int|false Request ID or false on failure */ public function createTermApprovalRequest( int $user_id, string $taxonomy, string $name, int $parent = 0, int $required_approvals = 3 ):int|false { global $wpdb; $table = $this->getRequestTable('term', $wpdb); try { $wpdb->query('START TRANSACTION'); // Step 1: Check if user already has a pending request for this term $existing = $wpdb->get_row($wpdb->prepare( "SELECT id, requested_by FROM $table WHERE name = %s AND taxonomy = %s AND parent = %d AND status = 'pending'", $name, $taxonomy, $parent )); if ($existing) { // Decode the requested_by JSON field $requestedBy = json_decode($existing->requested_by, true) ?: []; // Check if this user has already requested this term if (isset($requestedBy[$user_id])) { $wpdb->query('COMMIT'); return (int)$existing->id; } // Add this user to the requesters $requestedBy[$user_id] = get_userdata($user_id)->display_name; // Update the request with the new requester $updated = $wpdb->update( $table, ['requested_by' => json_encode($requestedBy)], ['id' => $existing->id] ); if (!$updated) { throw new Exception($wpdb->last_error); } $wpdb->query('COMMIT'); return (int)$existing->id; } $request = [ 'taxonomy' => $taxonomy, 'name' => $name, 'parent' => $parent ?: null, 'status' => 'pending', 'required_approvals' => $required_approvals, 'current_approvals' => 0, 'current_rejections' => 0, 'requested_by' => json_encode([$user_id => get_userdata($user_id)->display_name]), 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')), 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ]; $result = $this->createApprovalRequest('term', $request); if (!$result) { throw new Exception($wpdb->last_error); } $request_id = $wpdb->insert_id; $wpdb->query('COMMIT'); return $request_id; } catch (Exception $e) { $wpdb->query('ROLLBACK'); JVB()->error() ->log( '[ApprovalRoutes]:createTermApprovalRequest', "Error creating term approval request: " . $e->getMessage(), [ 'user_id' => $user_id, 'taxonomy' => $taxonomy, 'name' => $name ] ); return false; } } /** * Clean up expired approval requests and notify admin * * @return void */ public function cleanupExpiredApprovals(): void { global $wpdb; $tables = array_map(function ($table) use ($wpdb){ return $wpdb->prefix . BASE . 'approval_'.$table.'_requests'; }, $this->allTypes); foreach ($tables as $table) { $wpdb->query($wpdb->prepare( "UPDATE $table SET status = 'expired', updated_at = %s WHERE status = 'pending' AND expires_at < %s", current_time('mysql'), current_time('mysql') )); } // Clear caches $this->cache->flush(); } public function getApprovals(WP_REST_Request $request) { $user_id = get_current_user_id(); $params = $request->get_params(); $type = $params['type'] ?? 'all'; $status = $params['status'] ?? 'pending'; // Get appropriate approvals based on type if ($type === 'user' || $type === 'all') { $user_approvals = $this->getUserApprovals($status); } else { $user_approvals = []; } if ($type === 'term' || $type === 'all') { $term_approvals = $this->getTermApprovals($status); } else { $term_approvals = []; } return new WP_REST_Response([ 'user_approvals' => $user_approvals, 'term_approvals' => $term_approvals ]); } private function getUserApprovals(string $status = 'pending'): array { global $wpdb; $table = $wpdb->prefix . $this->userRequests; // Build the status condition $status_condition = ($status === 'all') ? "status IN ('pending', 'approved', 'rejected', 'expired')" : $wpdb->prepare("status = %s", $status); return $wpdb->get_results( "SELECT * FROM $table WHERE $status_condition ORDER BY created_at DESC" ); } private function getTermApprovals(string $status = 'pending'): array { global $wpdb; $table = $wpdb->prefix . $this->termRequests; // Build the status condition $status_condition = ($status === 'all') ? "status IN ('pending', 'approved', 'rejected', 'expired')" : $wpdb->prepare("status = %s", $status); return $wpdb->get_results( "SELECT * FROM $table WHERE $status_condition ORDER BY created_at DESC" ); } }