| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\Instance; |
| | | use JVBase\JVB; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\NotificationManager; |
| | | use JVBase\managers\Notifications\Notifications; |
| | | use JVBase\managers\Responses; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\managers\CustomTable; |
| | | use WP_REST_Request; |
| | |
| | | */ |
| | | class ResponseRoutes extends Rest |
| | | { |
| | | use Instance; |
| | | protected int $perPage = 20; |
| | | protected CustomTable $table; |
| | | protected CustomTable $karmaTable; |
| | |
| | | $this->cacheName = 'responses'; |
| | | $this->cacheTtl = 1800; // 30 minutes |
| | | parent::__construct(); |
| | | $this->cache = Cache::for($this->cacheName, 'response', $this->cacheTtl); |
| | | |
| | | $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 |
| | |
| | | */ |
| | | public function getResponses(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $item_id = (int) $request->get_param('item_id'); |
| | | |
| | | if (!$item_id) { |
| | | $params = $request->get_params(); |
| | | if (!array_key_exists('item_id', $params)) { |
| | | return $this->error('Missing item ID', 'missing_item_id'); |
| | | } |
| | | $item_id = absint($params['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); |
| | | return $this->success(Responses::getInstance()->getAllItemResponses($item_id, $args)); |
| | | } |
| | | |
| | | /** |
| | |
| | | return $this->unauthorized(); |
| | | } |
| | | |
| | | $operation_id = $data['id'] ?? uniqid('response_'); |
| | | $action = sanitize_text_field($data['action'] ?? ''); |
| | | |
| | | if (!in_array($action, ['create', 'delete', 'update'])) { |
| | |
| | | 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'] |
| | | ] |
| | | $responses = Responses::getInstance(); |
| | | $response = match($action) { |
| | | 'create' => $responses->respondTo( |
| | | absint($data['item_id']), |
| | | sanitize_text_field($data['content']), |
| | | $user_id, |
| | | wp_kses_post($data['response']), |
| | | isset($data['parent_id']) ? absint($data['parent_id']) : null |
| | | ), |
| | | 'update' => $responses->editResponse( |
| | | absint($data['response_id']), |
| | | wp_kses_post($data['response']??'') |
| | | ), |
| | | 'delete' => $responses->deleteResponse(absint($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); |
| | | if ($response['success']) { |
| | | return $this->success($response); |
| | | } |
| | | return $this->error($response['message']); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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); |
| | | |
| | | $responses = Responses::getInstance()->getAllItemResponses($item_id, $args); |
| | | // Format responses |
| | | $items = array_map([$this, 'formatItem'], $responses); |
| | | $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 |
| | | ] |
| | | ); |
| | | } |
| | | } |
| | | return $responses; |
| | | } |
| | | |
| | | /** |
| | |
| | | // Ordering |
| | | if ($request->has_param('orderby')) { |
| | | $orderby = $request->get_param('orderby'); |
| | | $valid = ['created_at', 'updated_at', 'upvotes', 'downvotes']; |
| | | $valid = ['created_at', 'updated_at', 'upvotes', 'downvotes', 'karmic_score']; |
| | | |
| | | if (in_array($orderby, $valid)) { |
| | | $args['orderby'] = $orderby; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | protected function formatItem(array $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'] = [ |
| | | if ($response['is_user_deleted']) { |
| | | $response['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 |
| | | ]; |
| | | } |
| | | $user = get_userdata($response['user_id']); |
| | | $response['user'] = [ |
| | | 'id' => $response['user_id'], |
| | | 'name' => $user->display_name, |
| | | 'url' => jvbUserProfileLink($response['user_id']), |
| | | ]; |
| | | } |
| | | |
| | | 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(); |
| | | return $response; |
| | | } |
| | | } |