<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\JVB;
|
use JVBase\rest\Rest;
|
use JVBase\managers\CustomTable;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Response Routes
|
*
|
* Handles threaded responses/comments for news items
|
*/
|
class ResponseRoutes extends Rest
|
{
|
protected int $perPage = 20;
|
protected CustomTable $table;
|
protected CustomTable $karmaTable;
|
|
public function __construct()
|
{
|
$this->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();
|
}
|
}
|