cacheName = 'responses'; $this->cacheTtl = 1800; // 30 minutes parent::__construct(); $this->table = CustomTable::for('responses'); $this->karmaTable = CustomTable::for('karma_response'); add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); add_action('deleted_user', [$this, 'handleUserDeletion'], 10, 1); } public function registerRoutes(): void { register_rest_route($this->namespace, '/response', [ [ 'methods' => 'GET', 'callback' => [$this, 'getResponses'], 'permission_callback' => 'is_user_logged_in' ], [ 'methods' => 'POST', 'callback' => [$this, 'handleResponseActions'], 'permission_callback' => 'is_user_logged_in' ] ]); } /** * Get responses for an item */ public function getResponses(WP_REST_Request $request): WP_REST_Response { $item_id = (int) $request->get_param('item_id'); if (!$item_id) { return $this->error('Missing item ID', 'missing_item_id'); } // Build query args $args = $this->buildQueryArgs($request); $cacheKey = $this->cache->generateKey(array_merge(['item_id' => $item_id], $args)); // Check headers for 304 Not Modified $headerCheck = $this->checkHeaders($request, $cacheKey); if ($headerCheck) { return $headerCheck; } // Check cache $cached = $this->cache->get($cacheKey); if ($cached) { return $this->success($cached); } // Get responses $responses = $this->getItemResponse($item_id, $args); if (is_wp_error($responses)) { return $this->notFound($responses->get_error_message()); } // Cache and return $this->cache->set($cacheKey, $responses); return $this->success($responses); } /** * Handle response actions (create/update/delete) */ public function handleResponseActions(WP_REST_Request $request): WP_REST_Response { $data = $request->get_params(); $user_id = (int) ($data['user'] ?? 0); if (!$this->userCheck($user_id)) { return $this->unauthorized('User verification failed'); } $operation_id = $data['id'] ?? uniqid('response_'); $action = sanitize_text_field($data['action'] ?? ''); if (!in_array($action, ['create', 'delete', 'update'])) { return $this->error('Invalid action', 'invalid_action'); } // Validate required fields for create if ($action === 'create') { if (!isset($data['item_id'], $data['response'], $data['content'])) { return $this->error('Missing required fields', 'missing_fields'); } } // Prepare data for queue $queue_data = match($action) { 'create' => [ 'item_id' => (int) $data['item_id'], 'parent_id' => isset($data['parent_id']) ? (int) $data['parent_id'] : null, 'response' => wp_kses_post($data['response']), 'content' => sanitize_text_field($data['content']) ], 'update' => [ 'response_id' => (int) $data['response_id'], 'response' => isset($data['response']) ? wp_kses_post($data['response']) : null, 'status' => isset($data['status']) && in_array($data['status'], ['published', 'hidden', 'flagged']) ? $data['status'] : null ], 'delete' => [ 'response_id' => (int) $data['response_id'] ] }; // Queue the operation JVB()->queue()->queueOperation( $action . '_response', $user_id, $queue_data, [ 'operation_id' => 'u' . $user_id . '_' . $operation_id, 'priority' => 'high' ] ); return $this->queued($operation_id); } /** * Get responses with karma calculations */ protected function getItemResponse(int $item_id, array $args = []): array|WP_Error { $defaults = [ 'page' => 1, 'per_page' => $this->perPage, 'orderby' => 'created_at', 'order' => 'DESC', 'parent_id' => null ]; $args = wp_parse_args($args, $defaults); // Verify post exists $post = get_post($item_id); if (!$post) { return new WP_Error('not_found', 'Item not found'); } // Build query with karma calculations $query = " SELECT r.*, COALESCE((SELECT COUNT(*) FROM {$this->table->getFullTableName()} WHERE parent_id = r.id), 0) as reply_count, COALESCE((SELECT COUNT(*) FROM {$this->karmaTable->getFullTableName()} WHERE item_id = r.id AND vote = 'up'), 0) as upvotes, COALESCE((SELECT COUNT(*) FROM {$this->karmaTable->getFullTableName()} WHERE item_id = r.id AND vote = 'down'), 0) as downvotes FROM {$this->table->getFullTableName()} r WHERE r.item_id = %d "; $query_args = [$item_id]; // Filter by parent_id 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']; } } // Get total count for pagination $count_query = str_replace( ['SELECT r.*,', 'COALESCE((SELECT COUNT(*) FROM', 'as reply_count,', 'as upvotes,', 'as downvotes'], ['SELECT COUNT(*)', '', '', '', ''], $query ); $count_query = preg_replace('/COALESCE\([^)]+\)[^,]*,?/', '', $count_query); $total_items = (int) $this->table->queryVar($count_query, $query_args); // Add ordering $order_column = in_array($args['orderby'], ['created_at', 'updated_at']) ? "r.{$args['orderby']}" : $args['orderby']; $query .= " ORDER BY {$order_column} {$args['order']}"; // Add pagination $query .= " LIMIT %d OFFSET %d"; $query_args[] = $args['per_page']; $query_args[] = ($args['page'] - 1) * $args['per_page']; // Get responses $responses = $this->table->queryResults($query, $query_args); // Format responses $items = array_map([$this, 'formatItem'], $responses); return [ 'items' => $items, 'has_more' => $args['page'] < ceil($total_items / $args['per_page']), 'total_items' => $total_items, 'total_pages' => (int) ceil($total_items / $args['per_page']) ]; } /** * Process queue operations */ 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; } return match($operation->type) { 'create_response' => $this->createResponse($operation, $data), 'update_response' => $this->updateResponse($data), 'delete_response' => $this->deleteResponse($data), default => $result }; } /** * Create a new response */ protected function createResponse(object $operation, array $data): array { $response_id = $this->table->insert([ 'item_id' => $data['item_id'], 'content' => $data['content'], 'user_id' => $operation->user_id, 'parent_id' => $data['parent_id'], 'response' => $data['response'], 'status' => 'published' ]); if (!$response_id) { $this->logError('Failed to insert response', [ 'data' => $data, 'error' => $this->table->getLastError() ]); return ['success' => false, 'result' => 'Failed to create response']; } // Send notifications $this->sendNotifications($data['item_id'], $data['parent_id'], $response_id, $operation->user_id); // Clear cache $this->cache->flush(); return ['success' => true, 'result' => $response_id]; } /** * Update a response */ protected function updateResponse(array $data): array { $update_data = []; if (isset($data['response'])) { $update_data['response'] = $data['response']; $update_data['updated_at'] = current_time('mysql'); } if (isset($data['status'])) { $update_data['status'] = $data['status']; } if (empty($update_data)) { return ['success' => false, 'result' => 'No fields to update']; } $updated = $this->table->update( $update_data, ['id' => $data['response_id']] ); if ($updated === false) { $this->logError('Failed to update response', [ 'data' => $data, 'error' => $this->table->getLastError() ]); return ['success' => false, 'result' => 'Failed to update response']; } $this->cache->flush(); return ['success' => true, 'result' => $updated]; } /** * Delete a response (or mark as deleted if it has replies) */ protected function deleteResponse(array $data): array { $response = $this->table->get(['id' => $data['response_id']]); if (!$response) { return ['success' => false, 'result' => 'Response not found']; } // Check if it has replies $has_replies = $this->table->where(['parent_id' => $data['response_id']])->countResults() > 0; if ($has_replies) { // Don't delete, just mark as deleted $updated = $this->table->update( [ 'response' => '[ deleted ]', 'status' => 'deleted', 'updated_at' => current_time('mysql') ], ['id' => $data['response_id']] ); $this->cache->flush(); return ['success' => true, 'result' => $updated]; } // No replies, safe to delete $deleted = $this->table->delete(['id' => $data['response_id']]); if ($deleted === false) { $this->logError('Failed to delete response', [ 'data' => $data, 'error' => $this->table->getLastError() ]); return ['success' => false, 'result' => 'Failed to delete response']; } $this->cache->flush(); return ['success' => true, 'result' => $deleted]; } /** * Send notifications for new responses */ protected function sendNotifications(int $item_id, ?int $parent_id, int $response_id, int $user_id): void { // Notify post author $post = get_post($item_id); if ($post && $post->post_author != $user_id) { JVB()->notification()->addNotification( $post->post_author, 'new_response', [ 'message' => 'Someone responded to your post', 'item_id' => $item_id, 'response_id' => $response_id ] ); } // Notify parent response author if this is a reply if ($parent_id) { $parent = $this->table->get(['id' => $parent_id]); if ($parent && $parent->user_id != $user_id) { JVB()->notification()->addNotification( $parent->user_id, 'response_reply', [ 'message' => 'Someone replied to your response', 'item_id' => $item_id, 'response_id' => $response_id ] ); } } } /** * Build query arguments from request */ 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->perPage))); $args = [ 'page' => $page, 'per_page' => $per_page, 'orderby' => 'created_at', 'order' => 'DESC' ]; // Parent filter (null = top-level only) if ($request->has_param('parent_id')) { $parent_id = $request->get_param('parent_id'); $args['parent_id'] = $parent_id === '0' ? null : (int) $parent_id; } // Ordering if ($request->has_param('orderby')) { $orderby = $request->get_param('orderby'); $valid = ['created_at', 'updated_at', 'upvotes', 'downvotes']; if (in_array($orderby, $valid)) { $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; } /** * Get child responses recursively */ protected function getChildren(int $item_id, int $parent_id): array { return $this->getItemResponse($item_id, ['parent_id' => $parent_id]); } /** * Format response for API output */ protected function formatItem(object $response): array { $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), 'reply_count' => (int) ($response->reply_count ?? 0) ]; // Handle deleted users if ($response->is_user_deleted) { $formatted['user'] = [ 'id' => null, 'name' => '[deleted user]', 'is_deleted' => true ]; } else { // Add artist info $artist = jvbContentFromUser($response->user_id); if (!empty($artist)) { $formatted['artist'] = [ 'name' => $artist['name'], 'url' => $artist['url'], 'shop' => $artist['shop'] ?? null ]; } } return $formatted; } /** * Handle user deletion by anonymizing responses */ public function handleUserDeletion(int $user_id): void { $updated = $this->table->update( [ 'is_user_deleted' => 1, 'updated_at' => current_time('mysql') ], ['user_id' => $user_id] ); $this->logError( 'Anonymized responses for deleted user', ['user_id' => $user_id, 'count' => $updated], 'info' ); $this->cache->flush(); } }