prefix . self::TABLE_NAME; } public static function votes_table_name() { global $wpdb; return $wpdb->prefix . self::VOTES_TABLE; } /** * Create the polls and poll_votes tables. */ public static function create_tables() { global $wpdb; $polls = self::table_name(); $votes = self::votes_table_name(); $charset = $wpdb->get_charset_collate(); $polls_sql = "CREATE TABLE $polls ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, beep_id BIGINT(20) UNSIGNED NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL, votes TEXT NOT NULL DEFAULT '[]', total_votes BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, ends_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY beep_id (beep_id) ) $charset;"; $votes_sql = "CREATE TABLE $votes ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, poll_id BIGINT(20) UNSIGNED NOT NULL, user_id BIGINT(20) UNSIGNED NOT NULL, option_index INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY poll_user (poll_id, user_id), KEY poll_id (poll_id), KEY user_id (user_id) ) $charset;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($polls_sql); dbDelta($votes_sql); } public static function register_routes() { // Create a poll for a beep register_rest_route('beep/v1', '/poll/(?P\d+)', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'create_poll'], 'permission_callback' => [__CLASS__, 'can_create_poll'], ]); // Get poll data for a beep register_rest_route('beep/v1', '/poll/(?P\d+)', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'get_poll'], 'permission_callback' => '__return_true', ]); // Vote on a poll register_rest_route('beep/v1', '/poll/(?P\d+)/vote', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'vote'], 'permission_callback' => [__CLASS__, 'can_vote'], ]); } public static function can_create_poll() { return is_user_logged_in() && !beep_user_is_banned(get_current_user_id()); } public static function can_vote() { return is_user_logged_in() && !beep_user_is_banned(get_current_user_id()); } /** * Create a poll for a beep post. * Expects JSON body: { "question": "...", "options": ["a", "b", ...], "ends_at": "YYYY-MM-DDTHH:MM:SS" } */ public static function create_poll($request) { $beep_id = (int) $request['beep_id']; $post = get_post($beep_id); if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) { return new WP_Error('not_found', 'Beep not found.', ['status' => 404]); } if (!current_user_can('edit_post', $beep_id)) { return new WP_Error('forbidden', 'Cannot create poll for this beep.', ['status' => 403]); } // Check if poll already exists if (self::poll_exists($beep_id)) { return new WP_Error('poll_exists', 'A poll already exists for this beep.', ['status' => 400]); } $body = $request->get_json_params(); $question = sanitize_text_field($body['question'] ?? ''); $options = $body['options'] ?? []; $ends_at = isset($body['ends_at']) ? sanitize_text_field($body['ends_at']) : null; if (empty($question)) { return new WP_Error('missing_question', 'Poll question is required.', ['status' => 400]); } if (count($options) < 2) { return new WP_Error('too_few_options', 'Poll must have at least 2 options.', ['status' => 400]); } if (count($options) > 6) { return new WP_Error('too_many_options', 'Poll can have at most 6 options.', ['status' => 400]); } // Sanitize options $options = array_map('sanitize_text_field', $options); $votes = array_fill(0, count($options), 0); global $wpdb; $table = self::table_name(); $result = $wpdb->insert($table, [ 'beep_id' => $beep_id, 'question' => $question, 'options' => json_encode($options), 'votes' => json_encode($votes), 'total_votes' => 0, 'ends_at' => $ends_at ? date('Y-m-d H:i:s', strtotime($ends_at)) : null, 'created_at' => current_time('mysql'), ], ['%d', '%s', '%s', '%s', '%d', '%s', '%s']); if ($result === false) { return new WP_Error('db_error', 'Failed to create poll.', ['status' => 500]); } $poll_id = $wpdb->insert_id; // Store poll_id in post meta for easy lookup update_post_meta($beep_id, 'beep_poll_id', $poll_id); return rest_ensure_response([ 'id' => $poll_id, 'beep_id' => $beep_id, 'question' => $question, 'options' => $options, 'votes' => $votes, 'total_votes' => 0, 'ends_at' => $ends_at, ]); } /** * Get poll data for a beep. */ public static function get_poll($request) { $beep_id = (int) $request['beep_id']; $poll = self::get_poll_by_beep_id($beep_id); if (!$poll) { return new WP_Error('not_found', 'Poll not found.', ['status' => 404]); } $user_id = get_current_user_id(); $user_vote = null; if ($user_id) { $user_vote = self::get_user_vote($poll['id'], $user_id); } return rest_ensure_response([ 'id' => $poll['id'], 'beep_id' => $poll['beep_id'], 'question' => $poll['question'], 'options' => $poll['options'], 'votes' => $poll['votes'], 'total_votes' => $poll['total_votes'], 'ends_at' => $poll['ends_at'], 'has_ended' => $poll['ends_at'] ? (strtotime($poll['ends_at']) < time()) : false, 'user_vote' => $user_vote, ]); } /** * Vote on a poll. * Expects JSON body: { "option_index": 0 } */ public static function vote($request) { $beep_id = (int) $request['beep_id']; $poll = self::get_poll_by_beep_id($beep_id); if (!$poll) { return new WP_Error('not_found', 'Poll not found.', ['status' => 404]); } // Check if poll has ended if ($poll['ends_at'] && strtotime($poll['ends_at']) < time()) { return new WP_Error('poll_ended', 'This poll has ended.', ['status' => 400]); } $body = $request->get_json_params(); $option_index = (int) ($body['option_index'] ?? -1); if ($option_index < 0 || $option_index >= count($poll['options'])) { return new WP_Error('invalid_option', 'Invalid option index.', ['status' => 400]); } $user_id = get_current_user_id(); // Check if user already voted $existing_vote = self::get_user_vote($poll['id'], $user_id); if ($existing_vote !== null) { // If same option, un-vote (toggle) if ($existing_vote === $option_index) { return rest_ensure_response(self::remove_vote($poll['id'], $user_id, $option_index, $beep_id)); } // Different option - change vote return rest_ensure_response(self::change_vote($poll['id'], $user_id, $existing_vote, $option_index, $beep_id)); } // Cast new vote return rest_ensure_response(self::cast_vote($poll['id'], $user_id, $option_index, $beep_id)); } private static function cast_vote($poll_id, $user_id, $option_index, $beep_id) { global $wpdb; $polls_table = self::table_name(); $votes_table = self::votes_table_name(); // Insert vote $wpdb->insert($votes_table, [ 'poll_id' => $poll_id, 'user_id' => $user_id, 'option_index' => $option_index, 'created_at' => current_time('mysql'), ], ['%d', '%d', '%d', '%s']); // Update poll votes and total $poll = self::get_poll_by_id($poll_id); $votes = $poll['votes']; $votes[$option_index]++; $total_votes = $poll['total_votes'] + 1; $wpdb->update( $polls_table, ['votes' => json_encode($votes), 'total_votes' => $total_votes], ['id' => $poll_id], ['%s', '%d'], ['%d'] ); return [ 'voted' => true, 'option_index' => $option_index, 'votes' => $votes, 'total_votes' => $total_votes, 'user_vote' => $option_index, ]; } private static function remove_vote($poll_id, $user_id, $option_index, $beep_id) { global $wpdb; $polls_table = self::table_name(); $votes_table = self::votes_table_name(); // Delete vote $wpdb->delete($votes_table, [ 'poll_id' => $poll_id, 'user_id' => $user_id, ], ['%d', '%d']); // Update poll votes and total $poll = self::get_poll_by_id($poll_id); $votes = $poll['votes']; $votes[$option_index] = max(0, $votes[$option_index] - 1); $total_votes = max(0, $poll['total_votes'] - 1); $wpdb->update( $polls_table, ['votes' => json_encode($votes), 'total_votes' => $total_votes], ['id' => $poll_id], ['%s', '%d'], ['%d'] ); return [ 'voted' => false, 'option_index' => null, 'votes' => $votes, 'total_votes' => $total_votes, 'user_vote' => null, ]; } private static function change_vote($poll_id, $user_id, $old_index, $new_index, $beep_id) { global $wpdb; $polls_table = self::table_name(); $votes_table = self::votes_table_name(); // Update vote $wpdb->update( $votes_table, ['option_index' => $new_index], ['poll_id' => $poll_id, 'user_id' => $user_id], ['%d'], ['%d', '%d'] ); // Update poll votes $poll = self::get_poll_by_id($poll_id); $votes = $poll['votes']; $votes[$old_index] = max(0, $votes[$old_index] - 1); $votes[$new_index]++; $wpdb->update( $polls_table, ['votes' => json_encode($votes)], ['id' => $poll_id], ['%s'], ['%d'] ); return [ 'voted' => true, 'option_index' => $new_index, 'votes' => $votes, 'total_votes' => $poll['total_votes'], 'user_vote' => $new_index, ]; } /** * Get poll by beep ID. */ public static function get_poll_by_beep_id($beep_id) { // First check post meta for poll_id $poll_id = get_post_meta($beep_id, 'beep_poll_id', true); if ($poll_id) { return self::get_poll_by_id($poll_id); } // Fallback to table lookup global $wpdb; $table = self::table_name(); $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $table WHERE beep_id = %d", $beep_id ), ARRAY_A); if (!$row) { return null; } return self::format_poll($row); } /** * Get poll by ID. */ public static function get_poll_by_id($poll_id) { global $wpdb; $table = self::table_name(); $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $poll_id ), ARRAY_A); if (!$row) { return null; } return self::format_poll($row); } /** * Check if poll exists for a beep. */ public static function poll_exists($beep_id) { return self::get_poll_by_beep_id($beep_id) !== null; } /** * Get user's vote for a poll. */ public static function get_user_vote($poll_id, $user_id) { global $wpdb; $table = self::votes_table_name(); $row = $wpdb->get_row($wpdb->prepare( "SELECT option_index FROM $table WHERE poll_id = %d AND user_id = %d", $poll_id, $user_id )); return $row ? (int) $row->option_index : null; } /** * Format poll row from database. */ private static function format_poll($row) { return [ 'id' => (int) $row['id'], 'beep_id' => (int) $row['beep_id'], 'question' => $row['question'], 'options' => json_decode($row['options'], true), 'votes' => json_decode($row['votes'], true), 'total_votes' => (int) $row['total_votes'], 'ends_at' => $row['ends_at'], 'created_at' => $row['created_at'], ]; } /** * Get poll results with percentages (for display). */ public static function get_results_with_percentages($poll) { $total = $poll['total_votes']; $results = []; foreach ($poll['options'] as $i => $option) { $votes = $poll['votes'][$i] ?? 0; $percentage = $total > 0 ? round(($votes / $total) * 100) : 0; $results[] = [ 'index' => $i, 'option' => $option, 'votes' => $votes, 'percentage' => $percentage, ]; } return $results; } }