cacheName = '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 { Route::for('approvals') ->get([$this, 'getApprovals']) ->args([ 'user' => 'integer|required', 'type' => 'string', 'status' => 'string|enum:pending,approved,rejected,expired', ]) ->auth(PermissionHandler::combine(['user', 'verified'])) ->rateLimit(30) ->post([$this, 'handleAction']) ->args([ 'user' => 'integer|required', 'request_id' => 'integer|required', 'action' => 'string|required|enum:approve,reject', 'type' => 'string|required', 'notes' => 'string', ]) ->auth(PermissionHandler::combine(['user', 'verified'])) ->rateLimit(3) ->register(); } /** * 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(fn($role) => BASE.$role, $this->userTypes), (array) $user->roles ); if (!empty($intersect)) { $user->add_cap('skip_moderation', false); $this->createArtistApprovalRequest($user_id); } } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleAction(WP_REST_Request $request):WP_REST_Response { $data = $request->get_params(); $request_id = absint($data['request_id']); $user_id = absint($data['user']); $action = sanitize_text_field($data['action']); $type = sanitize_text_field($data['type']); $notes = sanitize_text_field($data['notes'] ?? ''); if (!in_array($type, $this->allTypes)) { return Response::validationError(['message' => 'Invalid type']); } $result = $this->handleVote($type, $action, $request_id, $user_id, $notes); return $result ? Response::success(['message' => 'Vote recorded successfully']) : Response::error('Failed to record vote'); } /** * 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; } $requestTable = $this->getTableName($type, 'requests'); $voteTable = $this->getTableName($type, 'votes'); $requests = CustomTable::for($requestTable); $votes = CustomTable::for($voteTable); try { return $requests->transaction(function($requests) use ($votes, $request_id, $user_id, $vote, $notes, $type) { // Get the approval request $request = $requests->where(['id' => $request_id])->first(); if (!$request || $request->status !== 'pending') { throw new Exception("Invalid approval request"); } // Check if user already voted $existingVote = $votes->where([ 'request_id' => $request_id, 'user_id' => $user_id ])->first(); if ($existingVote) { if ($existingVote->vote !== $vote) { // Update vote $votes->where(['id' => $existingVote->id]) ->updateResults(['vote' => $vote]); return true; } throw new Exception("User has already voted on this request"); } // Insert new vote $votes->create([ 'request_id' => $request_id, 'user_id' => $user_id, 'vote' => $vote, 'notes' => $notes, ]); // Update request based on vote type $user = get_userdata($user_id); if ($vote === 'approve') { $this->handleApproval($requests, $request, $request_id, $user, $type); } else { $this->handleRejection($requests, $request, $request_id, $user, $type); } return true; }); } catch (Exception $e) { $this->logError('handleVote', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'request_id' => $request_id, 'vote' => $vote ]); return false; } } /** * Handle approval vote logic */ protected function handleApproval(CustomTable $table, object $request, int $request_id, $user, string $type): void { $approvers = json_decode($request->approved_by, true) ?: []; $approvers[$user->ID] = [ 'name' => $user->display_name, 'voted' => current_time('mysql') ]; $table->where(['id' => $request_id])->updateResults([ 'current_approvals' => $request->current_approvals + 1, 'approved_by' => json_encode($approvers), 'expires_at' => $this->rebuildExpiryDate() ]); // Check if threshold met if ($request->current_approvals + 1 >= $request->required_approvals) { match ($type) { 'term' => $this->makeTermLive($request), default => $this->completeVerification($request_id, $type), }; } } /** * Handle rejection vote logic */ protected function handleRejection(CustomTable $table, object $request, int $request_id, $user, string $type): void { $rejecters = json_decode($request->rejected_by, true) ?: []; $rejecters[$user->ID] = [ 'name' => $user->display_name, 'voted' => current_time('mysql') ]; $table->where(['id' => $request_id])->updateResults([ 'current_rejections' => $request->current_rejections + 1, 'rejected_by' => json_encode($rejecters), 'expires_at' => $this->rebuildExpiryDate() ]); // Check if threshold met if ($request->current_rejections + 1 >= $request->required_approvals) { match ($type) { 'term' => $this->makeTermUnalive($request), default => $this->denyVerification($request_id, $type), }; } } 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): int { $tableName = $this->getTableName($type, 'requests'); $id = CustomTable::for($tableName)->create($request); if (!$id) { throw new Exception('Failed to create approval request'); } return $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 */ /** * Create artist approval request - REFACTORED */ public function createArtistApprovalRequest(int $user_id): int|false { $userRole = jvbUserRole($user_id); $tableName = $this->getTableName($userRole, 'requests'); $table = CustomTable::for($tableName); try { return $table->transaction(function($table) use ($user_id) { // Check for existing request $existing = $table->where(['user_id' => $user_id])->first(); if ($existing) { return $existing->id; } $user_data = get_userdata($user_id); return $table->create([ 'user_id' => $user_id, 'status' => 'pending', 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')), 'current_approvals' => 0, 'current_rejections' => 0, 'required_approvals' => 3, // From config 'approved_by' => json_encode([]), 'rejected_by' => json_encode([]), ]); }); } catch (Exception $e) { $this->logError('createArtistApprovalRequest', [ 'error' => $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); } /** * Complete verification - REFACTORED */ protected function completeVerification(int $request_id, string $type = 'artist'): void { $tableName = $this->getTableName($type, 'requests'); $table = CustomTable::for($tableName); $table->where(['id' => $request_id])->updateResults([ 'status' => 'approved', 'approved_at' => current_time('mysql') ]); $request = $table->where(['id' => $request_id])->first(); if ($request && $request->user_id) { $user = new \WP_User($request->user_id); $user->add_cap('skip_moderation', true); JVB()->notification()->addNotification( $request->user_id, 'approval_granted', ['message' => 'Your account has been verified!'] ); } $this->cache->flush(); } protected function denyVerification(int $request_id, string $type = 'artist'): void { $tableName = $this->getTableName($type, 'requests'); $table = CustomTable::for($tableName); $table->where(['id' => $request_id])->updateResults([ 'status' => 'rejected', 'rejected_at' => current_time('mysql') ]); $request = $table->where(['id' => $request_id])->first(); if ($request && $request->user_id) { JVB()->notification()->addNotification( $request->user_id, 'approval_denied', ['message' => 'Your verification request was not approved.'] ); } $this->cache->flush(); } /** * 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 { $requestTable = CustomTable::for($this->getTableName($type, 'requests')); $voteTable = CustomTable::for($this->getTableName($type, 'votes')); $request = $requestTable->where(['id' => $requestID])->first(ARRAY_A); if (!$request) { return false; } // Get the votes for this request $votes = $voteTable ->where(['request_id' => $request['id']]) ->orderBy('created_at', 'ASC') ->getResults(ARRAY_A); // Join with user data for display names foreach ($votes as &$vote) { $user = get_userdata($vote['user_id']); $vote['approver_name'] = $user ? $user->display_name : 'Unknown'; } 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 { try { $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']; // Update request status CustomTable::for($this->getTableName('term', 'requests')) ->where(['id' => $request->id]) ->updateResults([ 'status' => 'approved', 'created_term' => $term_id ]); $userIDs = []; $approvedBy = []; $approvers = json_decode($request->approved_by, true) ?: []; $requesters = json_decode($request->requested_by, true) ?: []; $rejectors = json_decode($request->rejected_by, true) ?: []; foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) { $userIDs[] = $user_id; } foreach ($approvers as $user_id => $info) { $approvedBy[] = $info['name']; } $approvedBy = jvbCommaList($approvedBy); JVB()->notification()->addNotification( $userIDs, 'term_approved', [ 'term_id' => $term_id, 'term_name' => $term_name, 'taxonomy' => $taxonomy, 'approved_by' => $approvedBy ] ); return true; } catch (Exception $e) { $this->logError('makeTermLive', [ 'error' => $e->getMessage(), 'request_id' => $request->id, '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 { try { // Update request status CustomTable::for($this->getTableName('term', 'requests')) ->where(['id' => $request->id]) ->updateResults([ 'status' => 'rejected' ]); $userIDs = []; $rejectedBy = []; $approvers = json_decode($request->approved_by, true) ?: []; $requesters = json_decode($request->requested_by, true) ?: []; $rejectors = json_decode($request->rejected_by, true) ?: []; foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) { $userIDs[] = $user_id; } foreach ($rejectors as $user_id => $info) { $rejectedBy[] = $info['name']; } $rejectedBy = jvbCommaList($rejectedBy); JVB()->notification()->addNotification( $userIDs, 'term_rejected', [ 'term_name' => $request->name, 'taxonomy' => $request->taxonomy, 'rejected_by' => $rejectedBy ] ); return true; } catch (Exception $e) { $this->logError('makeTermUnalive', [ 'error' => $e->getMessage(), 'request_id' => $request->id, '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 { $table = CustomTable::for($this->getTableName('term', 'requests')); try { return $table->transaction(function($table) use ($user_id, $taxonomy, $name, $parent, $required_approvals) { // Check for existing request $existing = $table->where([ 'name' => $name, 'taxonomy' => $taxonomy, 'parent' => $parent, 'status' => 'pending' ])->first(); if ($existing) { $requestedBy = json_decode($existing->requested_by, true) ?: []; if (isset($requestedBy[$user_id])) { return (int)$existing->id; } $requestedBy[$user_id] = get_userdata($user_id)->display_name; $table->where(['id' => $existing->id])->updateResults([ 'requested_by' => json_encode($requestedBy) ]); return (int)$existing->id; } // Create new request return $this->createApprovalRequest('term', [ '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')), ]); }); } catch (Exception $e) { $this->logError('createTermApprovalRequest', [ 'error' => $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 { $now = current_time('mysql'); foreach ($this->allTypes as $type) { $tableName = $this->getTableName($type, 'requests'); CustomTable::for($tableName)->query( "UPDATE {table} SET status = 'expired' WHERE status = 'pending' AND expires_at < %s", [$now] ); } $this->cache->flush(); } protected function getTableName(string $type, string $suffix): string { return match ($type) { 'term' => "approval_term_{$suffix}", default => "approval_{$type}_{$suffix}", }; } public function getApprovals(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $type = sanitize_text_field($request->get_param('type') ?? 'all'); $status = sanitize_text_field($request->get_param('status') ?? 'pending'); if (!$this->checkUser($user_id)) { return $this->unauthorized(); } $cacheKey = compact('user_id', 'type', 'status'); $result = $this->cache->remember($cacheKey, function() use ($type, $status) { $data = []; if ($type === 'user' || $type === 'all') { $data['user_approvals'] = $this->getUserApprovals($status); } if ($type === 'term' || $type === 'all') { $data['term_approvals'] = $this->getTermApprovals($status); } return $data; }); return $this->success($result); } private function getUserApprovals(string $status = 'pending'): array { $table = CustomTable::for($this->getTableName('artist', 'requests')); $query = $table; if ($status !== 'all') { $query = $query->where(['status' => $status]); } return $query->orderBy('created_at', 'DESC')->getResults(ARRAY_A); } private function getTermApprovals(string $status = 'pending'): array { $table = CustomTable::for($this->getTableName('term', 'requests')); if ($status === 'all') { return $table->orderBy('created_at', 'DESC')->getResults(ARRAY_A); } return $table ->where(['status' => $status]) ->orderBy('created_at', 'DESC') ->getResults(ARRAY_A); } }