cache_name = 'responses'; parent::__construct(); $this->action = 'dash-'; $this->per_page = 20; add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); add_action('deleted_user', [$this, 'handleUserDeletion'], 10, 1); } /** * Registers response routes * @return void */ public function registerRoutes():void { register_rest_route($this->namespace, '/response', [ [ 'methods' => 'GET', 'callback' => [$this, 'getResponses'], 'permission_callback' => [$this, 'checkPermission'] ], [ 'methods' => 'POST', 'callback' => [$this, 'handleResponseActions'], 'permission_callback' => [$this, 'checkPermission'] ] ]); } /** * Get responses for a news post * @param WP_REST_Request $request * @return WP_REST_Response */ public function getResponses(WP_REST_Request $request):WP_REST_Response { $item_id = (int) $request->get_param('item_id'); error_log('Item ID: '.print_r($item_id, true)); if (!$item_id) { return new WP_REST_Response([ 'success'=> false, 'message' => 'Missing item ID' ]); } // Build query args $args = $this->buildQueryArgs($request); error_log('Args: '.print_r($args, true)); $responses = $this->getItemResponse($item_id, $args); error_log('Responses: '.print_r($responses, true)); // Return formatted response return new WP_REST_Response($responses); } /** * @param int $ID * @param string $postType * * @return void */ protected function clearItemCache(int $ID, string $postType):void { $args = [ BASE.'news' => $ID, 'post_type' => BASE.$postType, 'page' => 1, 'per_page' => 20, 'orderby' => 'created_at', 'order' => 'DESC' ]; $key = $this->cache->generateKey($args); $this->cache->invalidate($key); } /** * @param int $ID * @param array $args * * @return array|WP_Error */ public function getItemResponse(int $ID, array $args = []):array|WP_Error { $default = [ 'post_type' => BASE.'news', 'page' => 1, 'per_page' => 20, 'orderby' => 'created_at', 'order' => 'DESC' ]; $args = wp_parse_args($default, $args); $key = $this->cache->generateKey(array_merge([$args['post_type'] => $ID], $args)); $check = $this->cache->get($key); if ($check) { return $check; } // Verify post exists and is of correct type $post = get_post($ID); if (!$post || $post->post_type !== $args['post_type']) { return new WP_Error( self::ERROR_NOT_FOUND, 'Item not found', ['status' => 404] ); } // Execute the query global $wpdb; $table = $wpdb->prefix . BASE . 'responses'; // Get total count $total_query = "SELECT COUNT(*) FROM $table WHERE item_id = %d"; $total_args = [$ID]; // Add parent filter if specified if (isset($args['parent_id'])) { $total_query .= " AND parent_id " . ($args['parent_id'] === null ? "IS NULL" : "= %d"); if ($args['parent_id'] !== null) { $total_args[] = $args['parent_id']; } } $total_items = $wpdb->get_var($wpdb->prepare($total_query, $total_args)); // Build main query $query = "SELECT r.*, COALESCE((SELECT COUNT(*) FROM $table WHERE parent_id = r.id), 0) as reply_count, COALESCE((SELECT SUM(CASE WHEN vote = 'up' THEN 1 ELSE 0 END) FROM {$wpdb->prefix}" . BASE . "karma_response WHERE item_id = r.id), 0) as upvotes, COALESCE((SELECT SUM(CASE WHEN vote = 'down' THEN 1 ELSE 0 END) FROM {$wpdb->prefix}" . BASE . "karma_response WHERE item_id = r.id), 0) as downvotes FROM $table r WHERE r.item_id = %d"; $query_args = [$ID]; // Apply parent filter if (isset($args['parent_id'])) { $query .= " AND r.parent_id " . ($args['parent_id'] === null ? "IS NULL" : "= %d"); if ($args['parent_id'] !== null) { $query_args[] = $args['parent_id']; } } // Apply order $order_column = in_array($args['orderby'], ['created_at', 'updated_at']) ? "r." . $args['orderby'] : $args['orderby']; $query .= " ORDER BY $order_column " . $args['order']; // Apply pagination $query .= " LIMIT %d OFFSET %d"; $query_args[] = $args['per_page']; $query_args[] = ($args['page'] - 1) * $args['per_page']; // Get responses $responses = $wpdb->get_results($wpdb->prepare($query, $query_args)); // Format responses $items = array_map([$this, 'formatItem'], $responses); // Calculate pagination $total_pages = ceil($total_items / $args['per_page']); $return = [ 'items' => $items, 'has_more' => $args['page'] < $total_pages, 'total_items' => (int) $total_items, 'total_pages' => $total_pages ]; $this->cache->set($key, $return); return $return; } /** * Create a new response * @param WP_REST_Request $request * @return WP_REST_Response */ public function handleResponseActions(WP_REST_Request $request):WP_REST_Response { $data = $request->get_params(); $user_id = (int)$data['user']; if (!$this->userCheck($data['user'])) { return new WP_REST_Response([ 'success' => false, 'message' => 'User doesn\'t match. Bot?' ]); } $operation_id = $data['id'] ?? uniqid('response_'); // Validate required fields if (!array_key_exists('item_id', $data) || !array_key_exists('response', $data) || !array_key_exists('content', $data)) { error_log('Not enough data'); return new WP_REST_Response([ 'success' => false, 'message' => 'Missing required information.' ]); } // Prepare data for queue $queue_data = [ 'item_id' => (int) $data['item_id'], 'parent_id' => array_key_exists('parent_id', $data) ? (int) $data['parent_id'] : null, 'response' => wp_kses_post($data['response']), 'content'=> sanitize_text_field($data['content']) ]; error_log('Queue Data: '.print_r($queue_data, true)); $action = sanitize_text_field($data['action']); error_log('Action: '.print_r($action, true)); if (!in_array($action, ['create', 'delete', 'update'])) { return new WP_REST_Response([ 'success' => false, 'message' => 'Invalid action' ]); } error_log('Sanitized action. Here we go!'); // Add to queue $operation = JVB()->queue()->queueOperation( $action.'_response', $user_id, $queue_data, [ 'operation_id' => 'u' . $user_id . '_' . $operation_id, 'priority' => 'high' ] ); error_log('Queued for processing'); return new WP_REST_Response([ 'success' => true, 'message' => 'Item queued for processing' ]); } /** * Update a response * @param WP_REST_Request $request * @return WP_REST_Response */ public function updateResponse(WP_REST_Request $request):WP_REST_Response { $id = (int) $request->get_param('id'); $data = $request->get_params(); $user_id = (int) $data['user'] ?? get_current_user_id(); $operation_id = $data['id'] ?? uniqid('response_update_'); // Verify response exists global $wpdb; $table = $wpdb->prefix . BASE . 'responses'; $response = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id)); if (!$response) { return new WP_REST_Response([ 'success' => false, 'message' => 'Item not found.' ]); } // Check ownership or admin rights if ($response->user_id != $user_id) { return new WP_REST_Response([ 'success' => false, 'message' => 'You do not have permission to delete this response' ]); } // Prepare data for queue $queue_data = [ 'response_id' => $id, 'response' => !empty($data['response']) ? wp_kses_post($data['response']) : null, 'status' => !empty($data['status']) && in_array($data['status'], ['published', 'hidden', 'flagged']) ? $data['status'] : null ]; // Add to queue $operation = JVB()->queue()->queueOperation( 'update_response', $user_id, $queue_data, [ 'operation_id' => 'u' . $user_id . '_' . $operation_id, 'priority' => 'high', 'notification' => false, ] ); return new WP_REST_Response($operation); } /** * Delete a response * @param WP_REST_Request $request * @return WP_REST_Response */ public function deleteResponse(WP_REST_Request $request):WP_REST_Response { $id = (int) $request->get_param('id'); $user_id = get_current_user_id(); $operation_id = $request->get_param('id') ?? uniqid('response_delete_'); // Verify response exists global $wpdb; $table = $wpdb->prefix . BASE . 'responses'; $response = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id)); if (!$response) { return new WP_REST_Response([ 'success' => false, 'message' => 'Response not found', ]); } // Check ownership or admin rights if ($response->user_id != $user_id) { return new WP_REST_Response([ 'success' => false, 'msesage' => 'You do not have permission to delete this resposne', ]); } // Add to queue $operation = JVB()->queue()->queueOperation( 'delete_response', $user_id, ['response_id' => $id], [ 'operation_id' => 'u' . $user_id . '_' . $operation_id, 'priority' => 'high', 'notification' => false, ] ); return new WP_REST_Response([ 'success' => true, 'message' => 'Queued for processing' ]); } /** * Process operations from the queue * @param WP_Error|array $result * @param object $operation * @param array $data * @return WP_Error|array */ public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array { if (!in_array($operation->type, ['create_response', 'update_response', 'delete_response'])) { return $result; } global $wpdb; $table = $wpdb->prefix . BASE . 'responses'; switch ($operation->type) { case 'create_response': // Create new response $inserted = $wpdb->insert( $table, [ 'item_id' => $data['item_id'], 'content' => $data['content'], 'user_id' => $operation->user_id, 'parent_id' => $data['parent_id'], 'response' => $data['response'], 'status' => 'published', 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ], ['%d', '%d', '%d', '%s', '%s', '%s', '%s'] ); if (!$inserted) { error_log('Did not insert'.print_r($wpdb->last_error, true)); JVB()->error()->log( '[ResponseRoutes]:processOperation', 'Failed to insert response', ['data' => $data, 'error' => $wpdb->last_error], 'error' ); return [ 'success' => false, 'result' => 'Failed to create response' ]; } $response_id = $wpdb->insert_id; error_log('Response ID: '.print_r($response_id, true)); // Send notification to post author $post = get_post($data['item_id']); if ($post && $post->post_author != $operation->user_id) { JVB()->notification()->addNotification( $post->post_author, 'new_response', [ 'message' => 'Someone responded to your post', 'item_id' => $data['item_id'], 'response_id' => $response_id ] ); } // Send notification to parent response author if this is a reply if ($data['parent_id']) { $parent = $wpdb->get_row($wpdb->prepare( "SELECT user_id FROM $table WHERE id = %d", $data['parent_id'] )); if ($parent && $parent->user_id != $operation->user_id) { JVB()->notification()->addNotification( $parent->user_id, 'response_reply', [ 'message' => 'Someone replied to your response', 'item_id' => $data['item_id'], 'response_id' => $response_id ] ); } } $this->cache->forget($data['item_id']); return ['success' => true, 'result' => $response_id]; case 'update_response': $update_data = []; $update_format = []; if (array_key_exists('response', $data)) { $update_data['response'] = $data['response']; $update_data['updated_at'] = current_time('mysql'); $update_format[] = '%s'; $update_format[] = '%s'; } if (array_key_exists('status', $data)) { $update_data['status'] = $data['status']; $update_format[] = '%s'; } if (empty($update_data)) { return ['success' => false, 'result' => 'No fields to update']; } $updated = $wpdb->update( $table, $update_data, ['id' => $data['response_id']], $update_format, ['%d'] ); if ($updated === false) { JVB()->error()->log( '[ResponseRoutes]:processOperation', 'Failed to update response', ['data' => $data, 'error' => $wpdb->last_error], 'error' ); return [ 'success' => false, 'result' => 'Failed to update response' ]; } $this->cache->forget($data['item_id']); $this->cache->flush(); return ['success' => true, 'result' => $updated]; case 'delete_response': // Get response info before deleting $response = $wpdb->get_row($wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $data['response_id'] )); if (!$response) { return ['success' => false, 'result' => 'Response not found']; } // Check if this response has replies $has_replies = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE parent_id = %d", $data['response_id'] )) > 0; if ($has_replies) { // Don't delete, just mark as deleted and replace content $updated = $wpdb->update( $table, [ 'response' => '[ deleted ]', 'status' => 'deleted', 'updated_at' => current_time('mysql') ], ['id' => $data['response_id']], ['%s', '%s', '%s'], ['%d'] ); $this->cache->flush(); return ['success' => true, 'result' => $updated ]; } else { // No replies, safe to actually delete $deleted = $wpdb->delete( $table, ['id' => $data['response_id']], ['%d'] ); if ($deleted === false) { JVB()->error()->log( '[ResponseRoutes]:processOperation', 'Failed to delete response', ['data' => $data, 'error' => $wpdb->last_error], 'error' ); return [ 'success' => false, 'result' => 'Failed to delete response' ]; } $this->cache->forget($data['item_id']); $this->cache->flush(); return ['success' => true, 'result' => $deleted]; } } return $result; } /** * Build query arguments from request parameters * @param WP_REST_Request $request * @return array */ protected function buildQueryArgs(WP_REST_Request $request):array { $page = max(1, (int) $request->get_param('page') ?? 1); $per_page = min(100, max(1, (int) $request->get_param('per_page') ?? $this->per_page)); $args = [ 'page' => $page, 'per_page' => $per_page, 'orderby' => 'created_at', 'order' => 'DESC' ]; // Apply parent filter (null means top-level responses) if ($request->has_param('parent_id')) { $parent_id = $request->get_param('parent_id'); $args['parent_id'] = $parent_id === '0' ? null : (int) $parent_id; } // Apply ordering if ($request->has_param('orderby')) { $orderby = $request->get_param('orderby'); $valid_orderby = ['created_at', 'updated_at', 'upvotes', 'downvotes']; if (in_array($orderby, $valid_orderby)) { $args['orderby'] = $orderby; } } if ($request->has_param('order')) { $order = strtoupper($request->get_param('order')); if (in_array($order, ['ASC', 'DESC'])) { $args['order'] = $order; } } return $args; } /** * @param int $itemID * @param int $ID * * @return array */ protected function getChildren(int $itemID, int $ID):array { return $this->getItemResponse($itemID, ['parent_id' => $ID]); } /** * Format a response object for API output * @param object $response * @return array */ protected function formatItem(object $response):array { if ($response->is_user_deleted) { // For deleted users, show anonymous info $formatted = [ 'id' => (int) $response->id, 'item_id' => (int) $response->item_id, 'parent_id' => $response->parent_id ? (int) $response->parent_id : null, 'response' => $response->response, 'status' => $response->status, 'created_at' => $response->created_at, 'updated_at' => $response->updated_at, 'children' => $this->getChildren($response->item_id, $response->id), 'user' => [ 'id' => null, 'name' => '[deleted user]', 'is_deleted' => true ], 'upvotes' => (int) ($response->upvotes ?? 0), 'downvotes' => (int) ($response->downvotes ?? 0), 'karma' => (int) ($response->upvotes ?? 0) - (int) ($response->downvotes ?? 0) ]; } else { // Normal user processing as before error_log('Response: '.print_r($response, true)); $artist = jvbContentFromUser($response->user_id); $formatted = [ 'id' => (int) $response->id, 'item_id' => (int) $response->item_id, 'parent_id' => $response->parent_id ? (int) $response->parent_id : null, 'response' => $response->response, 'status' => $response->status, 'created_at' => $response->created_at, 'updated_at' => $response->updated_at, 'children' => $this->getChildren($response->item_id, $response->id), 'upvotes' => (int) ($response->upvotes ?? 0), 'downvotes' => (int) ($response->downvotes ?? 0), 'karma' => (int) ($response->upvotes ?? 0) - (int) ($response->downvotes ?? 0) ]; // Add artist info if available if (!empty($artist)) { $formatted['artist'] = [ 'name' => $artist['name'], 'url' => $artist['url'], 'shop' => $artist['shop'] ?? null ]; } } // Add reply count if available (for both deleted and non-deleted users) if (isset($response->reply_count)) { $formatted['reply_count'] = (int) $response->reply_count; } return $formatted; } /** * Handle user deletion by anonymizing their responses * @param int $user_id The deleted user ID */ public function handleUserDeletion(int $user_id):void { global $wpdb; $table = $wpdb->prefix . BASE . 'news_responses'; // Anonymize all responses by this user $wpdb->update( $table, [ 'is_user_deleted' => 1, // Keep user_id intact for internal tracking, but mark as deleted 'updated_at' => current_time('mysql') ], ['user_id' => $user_id], ['%d', '%s'], ['%d'] ); JVB()->error()->log( 'news_responses', 'Anonymized responses for deleted user', ['user_id' => $user_id, 'count' => $wpdb->rows_affected], 'info' ); } }