route = VoteRoutes::getInstance(); $this->key = $key; if (is_null($references)) { $references = match($key){ 'post', 'posts' => 'post', 'term','terms', 'taxonomy' => 'term', 'user', 'users' => 'user', 'response', 'responses' => 'response', default => false }; if (!$references) { error_log('[KarmaManager]::__construct No references set, and no default found. Could not create instance'); unset(self::$instances[$key]); return; } } $this->references = $references; $test = $this->defineTable(); if (!$test) { unset(self::$instances[$key]); } // $this->cache = Cache::for($this->references.'_karma'); // switch ($this->references) { // case 'post': // $this->cache->connect('post'); // break; // case 'term': // $this->cache->connect('term'); // break; // case 'user': // $this->cache->connect('user'); // break; // } if (!isset(self::$cache)) { self::$cache = Cache::for('user_karma')->user(); } $this->registerHooks(); } protected function registerHooks():void { if ($this->references === 'post') { add_filter('posts_join', [$this, 'maybeJoinKarmaOrderby'], 10, 2); add_filter('posts_orderby', [$this, 'maybeApplyKarmaOrderby'], 10, 2); } elseif ($this->references === 'term') { add_filter('terms_clauses', [$this, 'maybeApplyKarmaTermOrderby'], 10, 3); } add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } /** * Supported orderby keys: 'karma_upvotes', 'karma_downvotes', 'karma_score' */ protected function karmaOrderExpr(string $orderby, string $alias = 'jvb_karma'): ?string { return match ($orderby) { 'upvotes' => "COALESCE({$alias}.upvotes, 0)", 'downvotes' => "COALESCE({$alias}.downvotes, 0)", 'karmic_score' => "COALESCE({$alias}.upvotes, 0) - COALESCE({$alias}.downvotes, 0)", default => null, }; } public function maybeJoinKarmaOrderby(string $join, WP_Query $query): string { if (!in_array($query->get('orderby'), ['upvotes', 'downvotes', 'karmic_score'])) { return $join; } global $wpdb; $table = $this->table->getFullTableName(); $content = $query->get('post_type') ?: 'post'; $join .= $wpdb->prepare( " LEFT JOIN (SELECT item_id, SUM(vote = 'up') as upvotes, SUM(vote = 'down') as downvotes FROM {$table} WHERE content = %s GROUP BY item_id) jvb_karma ON jvb_karma.item_id = {$wpdb->posts}.ID", $content ); return $join; } public function maybeApplyKarmaOrderby(string $orderby, WP_Query $query): string { $expr = $this->karmaOrderExpr($query->get('orderby')); if (!$expr) { return $orderby; } $order = strtoupper($query->get('order')) === 'ASC' ? 'ASC' : 'DESC'; return "{$expr} {$order}"; } public function maybeApplyKarmaTermOrderby(array $clauses, array $taxonomies, array $args): array { $expr = $this->karmaOrderExpr($args['orderby'] ?? ''); if (!$expr) { return $clauses; } global $wpdb; $table = $this->table->getFullTableName(); $content = jvbNoBase($taxonomies[0] ?? ''); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN (SELECT item_id, SUM(vote = 'up') as upvotes, SUM(vote = 'down') as downvotes FROM {$table} WHERE content = %s GROUP BY item_id) jvb_karma ON jvb_karma.item_id = t.term_id", $content ); $clauses['orderby'] = "ORDER BY {$expr}"; $clauses['order'] = strtoupper($args['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; return $clauses; } public function karmaTable():CustomTable { return $this->table; } public static function getInstance(string $key):KarmaManager|false { if (!isset(self::$instances[$key])) { return false; } return self::$instances[$key]; } public static function for(string $key, ?string $references = null): KarmaManager { $key = sanitize_key($key); if (!isset(self::$instances[$key])) { self::$instances[$key] = new self($key, $references); } return self::$instances[$key]; } protected function getReferenceTable(CustomTable $table):array { switch ($this->references) { case 'post': case 'posts': return [ $table->getPostIDType(), $table->getPostTable(), 'ID' ]; case 'term': case 'terms': return [ $table->getTermIDType(), $table->getTermTable(), 'term_id' ]; case 'user': case 'users': return [ $table->getUserIDType(), $table->getUserTable(), 'ID' ]; } //If we get here, it is a custom table. $custom = CustomTable::getInstance($this->references); if (!$custom) { return [false,false,false]; } return [ 'bigint(20)', $custom->getFullTableName(), 'id' ]; } protected function defineTable():bool { $table = CustomTable::for('karma_'.$this->key); [$type, $referenceTable, $column] = $this->getReferenceTable($table); if (!$type) { error_log('[KarmaManager]::defineTable Attempted to build reference for invalid table: '.$this->references); CustomTable::destroyInstance('karma_'.$this->key); return false; } $table->setColumns([ 'id' => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT', 'item_id' => "{$type} NOT NULL", 'user_id' => "{$table->getUserIDType()} NOT NULL", 'content' => 'varchar(255) NOT NULL', 'vote' => "ENUM('up','down') NOT NULL", 'created_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP', 'updated_at'=> 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' ]); $table->setKeys([ ['key' => 'PRIMARY', 'value' => 'id'], ['key' => 'UNIQUE', 'value' => '`user_item` (`user_id`, `item_id`, `content`)'], '`item_vote` (`item_id`,`content`,`vote`)', '`item_id` (`item_id`)', '`user_id` (`user_id`)' ]); $base = BASE; $table->setConstraints([ "CONSTRAINT `{$base}kt_{$this->key}_{$type}_item_id` FOREIGN KEY (`item_id`) REFERENCES `{$referenceTable}` (`{$column}`) ON DELETE CASCADE", "CONSTRAINT `{$base}kt_{$this->key}_{$type}_user_id` FOREIGN KEY (`user_id`) REFERENCES `{$table->getUserTable()}` (`ID`) ON DELETE CASCADE" ]); $table->defineTable(); $this->table = $table; return true; } protected function response(?bool $success = null, string $message =''):array { $response = [ 'success' => is_null($success) ? 'partial' : $success, ]; if (!empty($message)) { $response['message'] = $message; } return $response; } protected function getItem(int $itemID, string $content):object|false { $based = jvbCheckBase($content); return match($this->references) { 'post' => get_post($itemID), 'term' => get_term($itemID, $based), 'user' => get_userdata($itemID), 'response' => Responses::getInstance()->responseExists($itemID), default => false //TODO if we do have custom tables, we'll have to set that up here }; } public function vote(int $userID, int $itemID, string $content, ?bool $vote = null):array { $content = jvbNoBase($content); $item = $this->getItem($itemID, $content); if (!$item || is_wp_error($item)) { return $this->response(false, 'Could not find target'); } $message = ''; //removing record if (is_null($vote)){ $success = $this->table->delete([ 'user_id' => $userID, 'item_id' => $itemID, 'content' => $content ]); if ($success) { $message = 'Vote removed successfully'; } } else { $success = $this->table->findOrCreate( [ 'user_id' => $userID, 'item_id' => $itemID, 'content' => $content ], [ 'vote' => $vote ? 'up' : 'down' ] ); if (!$success) { $message = 'Could not cast your vote'; } else { $message = 'Vote cast successfully'; } } self::$cache->forget($userID); Queue::getInstance()->queueOperation( 'karmic', 0, [ 'items' => [ 'itemID' => $itemID, 'content' => $content, ], 'type' => $this->references, ], [ 'chunk_key' => 'items', ] ); return $this->response((bool) $success, $message); } public function getCount(int $itemID, string $content):array { $upvotes = count($this->table->pluck( 'id', [ 'item_id' => $itemID, 'content' => $content, 'vote' => 'up' ])); $downvotes = count($this->table->pluck( 'id', [ 'item_id' => $itemID, 'content' => $content, 'vote' => 'down' ] )); return [ 'upvotes' => $upvotes, 'downvotes' => $downvotes, 'karmic_score' => $upvotes - $downvotes ]; } public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array { if ($operation->type !== 'karmic') { return $result; } if ($data['type'] !== $this->references) { return $result; } $results = []; foreach ($data['items'] as $item) { [$upvotes, $downvotes, $karma] = $this->getCount($item['itemID'], $item['content']); try { switch ($this->references) { case 'post': update_post_meta($item['itemID'], BASE.'upvotes', $upvotes); update_post_meta($item['itemID'], BASE.'downvotes', $downvotes); update_post_meta($item['itemID'], BASE.'karma', $karma); break; case 'term': update_term_meta($item['itemID'], BASE.'upvotes', $upvotes); update_term_meta($item['itemID'], BASE.'downvotes', $downvotes); update_term_meta($item['itemID'], BASE.'karma', $karma); break; case 'user': update_user_meta($item['itemID'], BASE.'upvotes', $upvotes); update_user_meta($item['itemID'], BASE.'downvotes', $downvotes); update_user_meta($item['itemID'], BASE.'karma', $karma); break; case 'response': Responses::getInstance()->updateKarma($item['itemID'], $upvotes, $downvotes, $karma); break; } $results[$item['itemID']] = true; } catch (Exception $e) { $results[$item['itemID']] = false; } } return [ 'success' => true, 'result' => $results ]; } public static function getUserVotes(int $userID):array { return self::$cache->remember( $userID, function() use($userID) { $votes = []; foreach (self::$instances as $instance) { $results = $instance->table->getMany([ 'where' => [ 'user_id' => $userID ] ]); foreach ($results as $result) { if (!array_key_exists($result['content'], $votes)) { $votes[$result['content']] = []; } $votes[$result['content']][$result['item_id']] = $result['vote']; } } return $votes; } ); } }