|\WP_Error */ public function bootstrap(array $session, \WP_REST_Request $request): array|\WP_Error { $url = trim((string) $request->get_param('url')); if ('' === $url) { $url = trim((string) $request->get_header('referer')); } $page_id = 0; $page = null; if ('' !== $url) { $canonical = Security::canonical_review_url($url); if (is_wp_error($canonical)) { return $canonical; } $page = $this->find_page((int) $session['review_id'], $canonical); $page_id = is_array($page) ? (int) $page['id'] : 0; } $comments = $this->with_session_ownership($this->comments((int) $session['review_id']), (int) $session['id']); return array( 'review' => array( 'id' => (int) $session['review_id'], 'state' => (string) $session['review_state'], ), 'session' => array( 'guestName' => (string) $session['guest_name'], ), 'page' => is_array($page) ? $this->page_output($page) : null, 'pages' => $this->pages((int) $session['review_id']), // The fixed sidebar is grouped by page, so bootstrap returns the // bounded review-wide set while the frontend independently filters pins // to the current canonical URL. This avoids a misleading empty sidebar // immediately after navigation to a page with no existing comments. 'comments' => $comments, ); } /** * @return array|\WP_Error */ public function create(array $session, \WP_REST_Request $request, ?Idempotency $idempotency = null, string $idempotency_route = '', string $idempotency_key = ''): array|\WP_Error { $params = $this->params($request); $body = $this->sanitise_body($params['body'] ?? '', !empty($params['allowEmptyBody'])); if (is_wp_error($body)) { return $body; } $url = isset($params['url']) ? (string) $params['url'] : (string) $request->get_header('referer'); $canonical = Security::canonical_review_url($url); if (is_wp_error($canonical)) { return $canonical; } $anchor = $this->sanitise_anchor($params['anchor'] ?? null, $canonical); if (is_wp_error($anchor)) { return $anchor; } $title = isset($params['pageTitle']) ? sanitize_text_field((string) $params['pageTitle']) : ''; $title = $this->truncate($title, 255); global $wpdb; $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $now = current_time('mysql', true); $anchor_json = wp_json_encode($anchor); if (!is_string($anchor_json)) { return Security::error('invalid_anchor', __('The comment anchor could not be saved.', 'commentmate'), 400); } if (!$this->begin_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $capacity = $this->reserve_feedback_capacity( (int) $session['review_id'], strlen($anchor_json) + (2 * strlen($body)) + strlen($title) + strlen((string) $session['guest_name']) + 3072 + ($idempotency instanceof Idempotency ? self::IDEMPOTENCY_RESPONSE_RESERVE_BYTES : 0), 2 ); if (is_wp_error($capacity)) { $this->rollback_mutation(); return $capacity; } $page_id = $this->ensure_page((int) $session['review_id'], $canonical, $title); if (is_wp_error($page_id)) { $this->rollback_mutation(); return $page_id; } $comment_number = $this->next_comment_number((int) $session['review_id']); if (is_wp_error($comment_number)) { $this->rollback_mutation(); return $comment_number; } $comment_inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $comments, array( 'review_id' => (int) $session['review_id'], 'comment_number' => $comment_number, 'page_id' => $page_id, 'author_session_id' => (int) $session['id'], 'author_name' => (string) $session['guest_name'], 'status' => 'open', 'anchor_json' => $anchor_json, 'body_html' => $body, 'screenshot_state' => 'unavailable', 'created_at' => $now, 'updated_at' => $now, ), array('%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s') ); $comment_id = (int) $wpdb->insert_id; $message_inserted = $comment_inserted && false !== $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $messages, array( 'comment_id' => $comment_id, 'author_session_id' => (int) $session['id'], 'author_user_id' => $session['_owner_user_id'] ?? null, 'body_html' => $body, 'created_at' => $now, ), array('%d', '%d', '%d', '%s', '%s') ); $message_id = $message_inserted ? (int) $wpdb->insert_id : 0; if (!$comment_inserted || !$message_inserted || $message_id < 1) { $this->rollback_mutation(); return Security::error('comment_not_saved', __('The comment could not be saved. Please try again.', 'commentmate'), 503); } $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback_mutation(); return Security::error('comment_not_saved', __('The comment could not be retrieved.', 'commentmate'), 503); } $payload = $this->output_for_session($this->complete_comment_output($comment), (int) $session['id']); if ($idempotency instanceof Idempotency && ('' === $idempotency_route || '' === $idempotency_key || !$idempotency->complete($session, $idempotency_route, $idempotency_key, 201, $payload))) { $this->rollback_mutation(); return Security::error('idempotency_unavailable', __('The comment could not be safely saved. Please try again.', 'commentmate'), 503); } if (!$this->commit_mutation()) { return Security::error('comment_not_saved', __('The comment could not be committed. Please try again.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], isset($session['_owner_user_id']) ? 'admin' : 'guest', (int) ($session['_owner_user_id'] ?? $session['id']), 'comment_created', array('comment_id' => $comment_id)); // Queue only after the database and idempotency transaction has committed. // Notification delivery is asynchronous and cannot change a successful // guest response if WordPress cron or mail is unavailable. if (!isset($session['_owner_user_id'])) { Notification_Service::queue_feedback((int) $session['review_id'], $comment_id, $message_id); } return $payload; } /** * @return array|\WP_Error */ public function update(array $session, int $comment_id, \WP_REST_Request $request): array|\WP_Error { $params = $this->params($request); $body = $this->sanitise_body($params['body'] ?? ''); if (is_wp_error($body)) { return $body; } if (!$this->begin_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback_mutation(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } if ((int) $comment['author_session_id'] !== (int) $session['id']) { $this->rollback_mutation(); return Security::error('comment_not_owned', __('Only the comment creator may edit it.', 'commentmate'), 403); } global $wpdb; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comments'), array( 'body_html' => $body, 'updated_at' => current_time('mysql', true), ), array('id' => $comment_id), array('%s', '%s'), array('%d') ); if (false === $updated) { $this->rollback_mutation(); return Security::error('comment_not_saved', __('The comment could not be updated.', 'commentmate'), 503); } // The original comment is also the first thread message. Keep both forms // consistent without granting the author control over later replies. $initial_message_updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('UPDATE %i SET body_html = %s, edited_at = %s WHERE comment_id = %d AND author_session_id = %d AND deleted_at IS NULL ORDER BY id ASC LIMIT 1', Database::table('commentmate_comment_messages'), $body, current_time('mysql', true), $comment_id, (int) $session['id']) ); if (false === $initial_message_updated) { $this->rollback_mutation(); return Security::error('comment_not_saved', __('The comment could not be updated.', 'commentmate'), 503); } $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback_mutation(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } $payload = $this->output_for_session($this->complete_comment_output($comment), (int) $session['id']); if (!$this->commit_mutation()) { return Security::error('comment_not_saved', __('The comment could not be committed.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], 'guest', (int) $session['id'], 'comment_edited', array('comment_id' => $comment_id)); return $payload; } /** * Guest status changes are forbidden; the authenticated owner uses Admin_Board. */ public function set_status(array $session, int $comment_id, \WP_REST_Request $request): array|\WP_Error { return Security::error('owner_required', __('Only the website owner can resolve or reopen comments.', 'commentmate'), 403); } /** * @return array|\WP_Error */ public function delete(array $session, int $comment_id): array|\WP_Error { if (!$this->begin_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback_mutation(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } if ((int) $comment['author_session_id'] !== (int) $session['id']) { $this->rollback_mutation(); return Security::error('comment_not_owned', __('Only the comment creator may delete it.', 'commentmate'), 403); } global $wpdb; $now = current_time('mysql', true); $deleted = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comments'), array( 'deleted_at' => $now, 'updated_at' => $now, ), array('id' => $comment_id), array('%s', '%s'), array('%d') ); if (false === $deleted) { $this->rollback_mutation(); return Security::error('comment_not_saved', __('The comment could not be deleted.', 'commentmate'), 503); } // Make every associated encrypted asset immediately unavailable and remove // it from the transient quota. The cleanup lifecycle later verifies removal // of the controlled private files; promoted Media Library files remain // preserved even though their review record is retired. $retired_assets = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('UPDATE %i SET disposition = CASE WHEN disposition = \'promoted\' THEN \'promoted\' ELSE \'deleted\' END, deleted_at = %s WHERE review_id = %d AND comment_id = %d AND deleted_at IS NULL', Database::table('commentmate_attachments'), $now, (int) $session['review_id'], $comment_id) ); if (false === $retired_assets) { $this->rollback_mutation(); return Security::error('comment_not_saved', __('The comment assets could not be retired.', 'commentmate'), 503); } if (!$this->commit_mutation()) { return Security::error('comment_not_saved', __('The comment could not be committed.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], 'guest', (int) $session['id'], 'comment_deleted', array('comment_id' => $comment_id)); return array('deleted' => true); } /** * @return array|\WP_Error */ public function reposition(array $session, int $comment_id, \WP_REST_Request $request): array|\WP_Error { if (!$this->begin_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback_mutation(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } if (!isset($session['_owner_user_id'])) { $this->rollback_mutation(); return Security::error('owner_required', __('Only a site administrator may repair comment placement.', 'commentmate'), 403); } $params = $this->params($request); // DOM placement is observed by the owner browser. Fence that observation // against the exact saved anchor; a stale repair cannot move a later pin. $original = $this->decode_anchor((string) $comment['anchor_json']); $observed = $this->sanitise_anchor($params['observedAnchor'] ?? null, (string) $comment['canonical_url']); if (true !== ($params['placementMismatch'] ?? false) || is_wp_error($observed) || $observed != $original) { $this->rollback_mutation(); return Security::error('placement_observation_required', __('Reload and confirm the placement warning before repairing this pin.', 'commentmate'), 409); } $url = isset($params['url']) ? (string) $params['url'] : (string) ($this->decode_anchor((string) $comment['anchor_json'])['url'] ?? ''); $canonical = Security::canonical_review_url($url); if (is_wp_error($canonical)) { $this->rollback_mutation(); return $canonical; } if (!hash_equals((string) $comment['canonical_url'], $canonical)) { $this->rollback_mutation(); return Security::error('cross_page_reposition', __('Move this comment from its current page by creating a new comment.', 'commentmate'), 400); } $anchor = $this->sanitise_anchor($params['anchor'] ?? null, $canonical); if (is_wp_error($anchor)) { $this->rollback_mutation(); return $anchor; } $anchor_json = wp_json_encode($anchor); if (!is_string($anchor_json)) { $this->rollback_mutation(); return Security::error('invalid_anchor', __('The comment anchor could not be saved.', 'commentmate'), 400); } global $wpdb; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comments'), array( 'anchor_json' => $anchor_json, 'updated_at' => current_time('mysql', true), ), array('id' => $comment_id), array('%s', '%s'), array('%d') ); if (false === $updated) { $this->rollback_mutation(); return Security::error('comment_not_saved', __('The comment position could not be updated.', 'commentmate'), 503); } $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback_mutation(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } $payload = $this->output_for_session($this->complete_comment_output($comment), (int) $session['id']); if (!$this->commit_mutation()) { return Security::error('comment_not_saved', __('The comment position could not be committed.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], 'admin', (int) $session['_owner_user_id'], 'comment_repositioned', array('comment_id' => $comment_id)); return $payload; } /** * Returns the visible thread for one comment. The REST boundary has already * authenticated the guest session, but this method still scopes every query * to that session's review. * * @return list>|\WP_Error */ public function messages(array $session, int $comment_id): array|\WP_Error { $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } $messages = $this->messages_for_comments((int) $session['review_id'], array($comment_id)); $output = array(); foreach ($messages[$comment_id] ?? array() as $message) { $output[] = $this->output_message_for_session($message, (int) $session['id']); } return $output; } /** * Adds a guest-authored reply to a visible comment. * * @return array|\WP_Error */ public function create_message(array $session, int $comment_id, \WP_REST_Request $request, ?Idempotency $idempotency = null, string $idempotency_route = '', string $idempotency_key = ''): array|\WP_Error { $params = $this->params($request); $body = $this->sanitise_body($params['body'] ?? ''); if (is_wp_error($body)) { return $body; } if (!$this->begin_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $comment = $this->find_comment((int) $session['review_id'], $comment_id); if (!is_array($comment)) { $this->rollback_mutation(); return Security::error('comment_not_found', __('That comment no longer exists.', 'commentmate'), 404); } $capacity = $this->reserve_feedback_capacity( (int) $session['review_id'], strlen($body) + 512 + ($idempotency instanceof Idempotency ? self::IDEMPOTENCY_RESPONSE_RESERVE_BYTES : 0), 1 ); if (is_wp_error($capacity)) { $this->rollback_mutation(); return $capacity; } global $wpdb; $now = current_time('mysql', true); $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comment_messages'), array( 'comment_id' => $comment_id, 'author_session_id' => (int) $session['id'], 'author_user_id' => $session['_owner_user_id'] ?? null, 'body_html' => $body, 'created_at' => $now, ), array('%d', '%d', '%d', '%s', '%s') ); $message_id = (int) $wpdb->insert_id; $touch = $inserted && false !== $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comments'), array('updated_at' => $now), array('id' => $comment_id, 'review_id' => (int) $session['review_id'], 'deleted_at' => null), array('%s'), array('%d', '%d', '%s') ); if (!$inserted || !$touch || $message_id < 1) { $this->rollback_mutation(); return Security::error('message_not_saved', __('The reply could not be saved. Please try again.', 'commentmate'), 503); } $message = $this->find_message((int) $session['review_id'], $comment_id, $message_id); if (!is_array($message)) { $this->rollback_mutation(); return Security::error('message_not_saved', __('The reply could not be retrieved.', 'commentmate'), 503); } $payload = $this->output_message_for_session($this->message_output($message), (int) $session['id']); if ($idempotency instanceof Idempotency && ('' === $idempotency_route || '' === $idempotency_key || !$idempotency->complete($session, $idempotency_route, $idempotency_key, 201, $payload))) { $this->rollback_mutation(); return Security::error('idempotency_unavailable', __('The reply could not be safely saved. Please try again.', 'commentmate'), 503); } if (!$this->commit_mutation()) { return Security::error('message_not_saved', __('The reply could not be committed. Please try again.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], isset($session['_owner_user_id']) ? 'admin' : 'guest', (int) ($session['_owner_user_id'] ?? $session['id']), 'message_created', array('comment_id' => $comment_id, 'message_id' => $message_id)); // A reply reaches the notification queue only after its transaction is // durable, preventing rollback or idempotency retries from mailing a // feedback item that does not exist. if (!isset($session['_owner_user_id'])) { Notification_Service::queue_feedback((int) $session['review_id'], $comment_id, $message_id); } return $payload; } /** * @return array|\WP_Error */ public function update_message(array $session, int $comment_id, int $message_id, \WP_REST_Request $request): array|\WP_Error { $params = $this->params($request); $body = $this->sanitise_body($params['body'] ?? ''); if (is_wp_error($body)) { return $body; } if (!$this->begin_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $message = $this->find_message((int) $session['review_id'], $comment_id, $message_id); if (!is_array($message)) { $this->rollback_mutation(); return Security::error('message_not_found', __('That reply no longer exists.', 'commentmate'), 404); } if ((int) $message['author_session_id'] !== (int) $session['id']) { $this->rollback_mutation(); return Security::error('message_not_owned', __('Only the reply creator may edit it.', 'commentmate'), 403); } global $wpdb; $initial_message_id = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT id FROM %i WHERE comment_id = %d ORDER BY id ASC LIMIT 1', Database::table('commentmate_comment_messages'), $comment_id) ); if ($initial_message_id === $message_id) { $this->rollback_mutation(); return Security::error('initial_message_protected', __('Use the comment controls to edit the original comment.', 'commentmate'), 409); } $now = current_time('mysql', true); $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comment_messages'), array('body_html' => $body, 'edited_at' => $now), array('id' => $message_id, 'comment_id' => $comment_id, 'deleted_at' => null), array('%s', '%s'), array('%d', '%d', '%s') ); $touch = false !== $updated && false !== $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comments'), array('updated_at' => $now), array('id' => $comment_id, 'review_id' => (int) $session['review_id'], 'deleted_at' => null), array('%s'), array('%d', '%d', '%s') ); if (false === $updated || !$touch) { $this->rollback_mutation(); return Security::error('message_not_saved', __('The reply could not be updated.', 'commentmate'), 503); } $message = $this->find_message((int) $session['review_id'], $comment_id, $message_id); if (!is_array($message)) { $this->rollback_mutation(); return Security::error('message_not_found', __('That reply no longer exists.', 'commentmate'), 404); } $payload = $this->output_message_for_session($this->message_output($message), (int) $session['id']); if (!$this->commit_mutation()) { return Security::error('message_not_saved', __('The reply could not be committed.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], 'guest', (int) $session['id'], 'message_edited', array('comment_id' => $comment_id, 'message_id' => $message_id)); return $payload; } /** * @return array|\WP_Error */ public function delete_message(array $session, int $comment_id, int $message_id): array|\WP_Error { if (!$this->begin_mutation($session)) { return Security::error('session_revoked', __('This review session is no longer active.', 'commentmate'), 401); } $message = $this->find_message((int) $session['review_id'], $comment_id, $message_id); if (!is_array($message)) { $this->rollback_mutation(); return Security::error('message_not_found', __('That reply no longer exists.', 'commentmate'), 404); } if ((int) $message['author_session_id'] !== (int) $session['id']) { $this->rollback_mutation(); return Security::error('message_not_owned', __('Only the reply creator may delete it.', 'commentmate'), 403); } global $wpdb; $initial_message_id = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT id FROM %i WHERE comment_id = %d ORDER BY id ASC LIMIT 1', Database::table('commentmate_comment_messages'), $comment_id) ); if ($initial_message_id === $message_id) { $this->rollback_mutation(); return Security::error('initial_message_protected', __('Use the comment controls to delete the original comment.', 'commentmate'), 409); } $now = current_time('mysql', true); $deleted = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comment_messages'), array('deleted_at' => $now), array('id' => $message_id, 'comment_id' => $comment_id, 'deleted_at' => null), array('%s'), array('%d', '%d', '%s') ); $retired_assets = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('UPDATE %i SET disposition = CASE WHEN disposition = \'promoted\' THEN \'promoted\' ELSE \'deleted\' END, deleted_at = %s WHERE review_id = %d AND comment_id = %d AND message_id = %d AND deleted_at IS NULL', Database::table('commentmate_attachments'), $now, (int) $session['review_id'], $comment_id, $message_id) ); $touch = false !== $deleted && false !== $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_comments'), array('updated_at' => $now), array('id' => $comment_id, 'review_id' => (int) $session['review_id'], 'deleted_at' => null), array('%s'), array('%d', '%d', '%s') ); if (false === $deleted || false === $retired_assets || !$touch) { $this->rollback_mutation(); return Security::error('message_not_saved', __('The reply could not be deleted.', 'commentmate'), 503); } if (!$this->commit_mutation()) { return Security::error('message_not_saved', __('The reply could not be committed.', 'commentmate'), 503); } Audit::record((int) $session['review_id'], 'guest', (int) $session['id'], 'message_deleted', array('comment_id' => $comment_id, 'message_id' => $message_id)); return array('deleted' => true); } /** * @return list> */ private function pages(int $review_id): array { global $wpdb; $pages = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT id, canonical_url, page_title, created_at, updated_at FROM %i WHERE review_id = %d ORDER BY updated_at DESC LIMIT 500', Database::table('commentmate_pages'), $review_id), ARRAY_A ); return array_map(array($this, 'page_output'), is_array($pages) ? $pages : array()); } /** * @return list> */ private function comments(int $review_id, int $page_id = 0): array { global $wpdb; $comments = Database::table('commentmate_comments'); $pages = Database::table('commentmate_pages'); if ($page_id > 0) { $query = $wpdb->prepare('SELECT c.*, p.canonical_url, p.page_title FROM %i AS c INNER JOIN %i AS p ON p.id = c.page_id WHERE c.review_id = %d AND c.page_id = %d AND c.deleted_at IS NULL ORDER BY c.comment_number ASC, c.id ASC LIMIT 500', $comments, $pages, $review_id, $page_id); } else { $query = $wpdb->prepare('SELECT c.*, p.canonical_url, p.page_title FROM %i AS c INNER JOIN %i AS p ON p.id = c.page_id WHERE c.review_id = %d AND c.deleted_at IS NULL ORDER BY c.comment_number ASC, c.id ASC LIMIT 500', $comments, $pages, $review_id); } $rows = $wpdb->get_results($query, ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Both branches immediately above prepare identifiers and values; no request data reaches SQL without placeholders. Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $rows = is_array($rows) ? $rows : array(); $comment_ids = array_values(array_filter(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows))); $attachments = $this->attachments_for_comments($review_id, $comment_ids); $messages = $this->messages_for_comments($review_id, $comment_ids); $output = array(); foreach ($rows as $row) { $comment = $this->comment_output($row); $id = (int) $comment['id']; $comment['assets'] = $attachments[$id] ?? array(); $comment['messages'] = $messages[$id] ?? array(); $output[] = $comment; } return $output; } /** * @return array|null */ private function find_page(int $review_id, string $url): ?array { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT * FROM %i WHERE review_id = %d AND url_hash = %s LIMIT 1', Database::table('commentmate_pages'), $review_id, Security::hash($url)), ARRAY_A ); return is_array($row) ? $row : null; } /** * The review row is already locked by begin_mutation(), which serialises * this value across guest sessions. Deleted comments remain in the max so a * display number is never reassigned to another comment. * * @return int|\WP_Error */ private function next_comment_number(int $review_id): int|\WP_Error { global $wpdb; $next = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT COALESCE(MAX(comment_number), 0) + 1 FROM %i WHERE review_id = %d', Database::table('commentmate_comments'), $review_id) ); if (!is_numeric($next) || (int) $next < 1) { return Security::error('comment_not_saved', __('The next comment number could not be allocated.', 'commentmate'), 503); } return (int) $next; } /** * @return int|\WP_Error */ private function ensure_page(int $review_id, string $url, string $title): int|\WP_Error { $existing = $this->find_page($review_id, $url); if (is_array($existing)) { return (int) $existing['id']; } global $wpdb; $now = current_time('mysql', true); $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. Database::table('commentmate_pages'), array( 'review_id' => $review_id, 'url_hash' => Security::hash($url), 'canonical_url' => $url, 'page_title' => $title, 'created_at' => $now, 'updated_at' => $now, ), array('%d', '%s', '%s', '%s', '%s', '%s') ); if (false === $inserted) { // A concurrent request may have won the unique index race. $existing = $this->find_page($review_id, $url); if (is_array($existing)) { return (int) $existing['id']; } return Security::error('page_not_saved', __('The reviewed page could not be saved.', 'commentmate'), 503); } return (int) $wpdb->insert_id; } /** * @return array|null */ private function find_comment(int $review_id, int $comment_id): ?array { if ($comment_id < 1) { return null; } global $wpdb; $comments = Database::table('commentmate_comments'); $pages = Database::table('commentmate_pages'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT c.*, p.canonical_url, p.page_title FROM %i AS c INNER JOIN %i AS p ON p.id = c.page_id WHERE c.review_id = %d AND c.id = %d AND c.deleted_at IS NULL LIMIT 1', $comments, $pages, $review_id, $comment_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * @return array|null */ private function find_message(int $review_id, int $comment_id, int $message_id): ?array { if ($review_id < 1 || $comment_id < 1 || $message_id < 1) { return null; } global $wpdb; $messages = Database::table('commentmate_comment_messages'); $comments = Database::table('commentmate_comments'); $sessions = Database::table('commentmate_guest_sessions'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT m.id, m.comment_id, m.author_session_id, m.author_user_id, m.body_html, m.created_at, m.edited_at, COALESCE(s.guest_name, c.author_name, \'Guest\') AS author_name FROM %i AS m INNER JOIN %i AS c ON c.id = m.comment_id LEFT JOIN %i AS s ON s.id = m.author_session_id WHERE c.review_id = %d AND c.id = %d AND m.id = %d AND c.deleted_at IS NULL AND m.deleted_at IS NULL LIMIT 1', $messages, $comments, $sessions, $review_id, $comment_id, $message_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * Builds the complete safe frontend form for one comment. Mutation handlers * call this while their transaction is open so a returned response matches * the same comment/messages/assets snapshot that is committed. * * @param array $row * @return array */ private function complete_comment_output(array $row): array { $comment = $this->comment_output($row); $review_id = (int) ($row['review_id'] ?? 0); $comment_id = (int) ($row['id'] ?? 0); $attachments = $this->attachments_for_comments($review_id, array($comment_id)); $messages = $this->messages_for_comments($review_id, array($comment_id)); $comment['assets'] = $attachments[$comment_id] ?? array(); $comment['messages'] = $messages[$comment_id] ?? array(); return $comment; } /** * @param list $comment_ids * @return array>> */ private function attachments_for_comments(int $review_id, array $comment_ids): array { if ($review_id < 1 || empty($comment_ids)) { return array(); } $ids = array_values(array_unique(array_filter(array_map('absint', $comment_ids)))); if (empty($ids)) { return array(); } global $wpdb; $messages = Database::table('commentmate_comment_messages'); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare( 'SELECT a.id, a.comment_id, a.message_id, a.mime_type, a.original_name, a.byte_size, a.disposition, a.media_attachment_id, a.created_at FROM %i AS a LEFT JOIN %i AS m ON m.id = a.message_id AND m.comment_id = a.comment_id AND m.deleted_at IS NULL WHERE a.review_id = %d AND a.comment_id IN (' . implode(',', array_fill(0, count($ids), '%d')) . ') AND a.deleted_at IS NULL AND (a.message_id IS NULL OR m.id IS NOT NULL) ORDER BY a.comment_id ASC, a.id ASC', array_merge(array(Database::table('commentmate_attachments'), $messages, $review_id), $ids) ), ARRAY_A ); $output = array(); foreach (is_array($rows) ? $rows : array() as $row) { $comment_id = (int) $row['comment_id']; $output[$comment_id] ??= array(); $name = (string) $row['original_name']; $output[$comment_id][] = array( 'id' => (int) $row['id'], 'commentId' => $comment_id, 'messageId' => null === $row['message_id'] ? null : (int) $row['message_id'], 'mimeType' => (string) $row['mime_type'], 'originalName' => $name, 'byteSize' => (int) $row['byte_size'], 'disposition' => (string) $row['disposition'], 'mediaAttachmentId' => null === $row['media_attachment_id'] ? null : (int) $row['media_attachment_id'], 'mediaUrl' => Asset_Service::promoted_media_url($row), 'createdAt' => (string) $row['created_at'], 'kind' => str_starts_with($name, 'commentmate-screenshot-') ? 'screenshot' : 'attachment', ); } return $output; } /** * @param list $comment_ids * @return array>> */ private function messages_for_comments(int $review_id, array $comment_ids): array { if ($review_id < 1 || empty($comment_ids)) { return array(); } $ids = array_values(array_unique(array_filter(array_map('absint', $comment_ids)))); if (empty($ids)) { return array(); } global $wpdb; $messages = Database::table('commentmate_comment_messages'); $comments = Database::table('commentmate_comments'); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare( "SELECT m.id, m.comment_id, m.author_session_id, m.author_user_id, m.body_html, m.created_at, m.edited_at, COALESCE(s.guest_name, c.author_name, 'Guest') AS author_name FROM %i AS m INNER JOIN %i AS c ON c.id = m.comment_id LEFT JOIN %i AS s ON s.id = m.author_session_id WHERE c.review_id = %d AND m.comment_id IN (" . implode(',', array_fill(0, count($ids), '%d')) . ") AND m.deleted_at IS NULL ORDER BY m.comment_id ASC, m.created_at ASC, m.id ASC", array_merge(array($messages, $comments, Database::table('commentmate_guest_sessions'), $review_id), $ids) ), ARRAY_A ); $output = array(); foreach (is_array($rows) ? $rows : array() as $row) { $comment_id = (int) $row['comment_id']; $output[$comment_id] ??= array(); $output[$comment_id][] = $this->message_output($row); } return $output; } /** * @return string|\WP_Error */ private function sanitise_body(mixed $body, bool $allow_empty = false): string|\WP_Error { if (!is_scalar($body)) { return Security::error('invalid_comment', __('Comment text is required.', 'commentmate'), 400); } $body = trim((string) $body); if (strlen($body) > self::MAX_BODY_BYTES) { return Security::error('invalid_comment', __('Comment text must be between 1 and 20,000 bytes.', 'commentmate'), 400); } $allowed = array( 'p' => array(), 'h1' => array(), 'h2' => array(), 'h3' => array(), 'h4' => array(), 'h5' => array(), 'h6' => array(), 'br' => array(), 'strong' => array(), 'b' => array(), 'em' => array(), 'i' => array(), 'u' => array(), 'a' => array('href' => true, 'title' => true), 'ul' => array(), 'ol' => array(), 'li' => array(), 'code' => array(), ); $sanitised = trim(wp_kses($body, $allowed)); if ('' === trim(wp_strip_all_tags($sanitised)) && !$allow_empty) { return Security::error('invalid_comment', __('Comment text is required.', 'commentmate'), 400); } return $sanitised; } /** * @return array|\WP_Error */ private function sanitise_anchor(mixed $anchor, string $url): array|\WP_Error { if (!is_array($anchor)) { return Security::error('invalid_anchor', __('A comment anchor is required.', 'commentmate'), 400); } $selector = $this->sanitise_anchor_string($anchor['selector'] ?? '', 2000); $dom_path = $this->sanitise_anchor_string($anchor['domPath'] ?? '', 3000); $text_fingerprint = $this->truncate(sanitize_text_field((string) ($anchor['textFingerprint'] ?? '')), 500); $title = $this->truncate(sanitize_text_field((string) ($anchor['title'] ?? '')), 255); if ('' === $selector && '' === $dom_path && '' === $text_fingerprint && !isset($anchor['documentX'], $anchor['documentY'])) { return Security::error('invalid_anchor', __('The comment needs an element, text, or coordinate anchor.', 'commentmate'), 400); } return array( 'url' => $url, 'title' => $title, 'selector' => $selector, 'domPath' => $dom_path, 'textFingerprint' => $text_fingerprint, 'relativeX' => $this->number($anchor['relativeX'] ?? null, -1.0, 2.0), 'relativeY' => $this->number($anchor['relativeY'] ?? null, -1.0, 2.0), 'documentX' => $this->number($anchor['documentX'] ?? null, -1000000.0, 1000000.0), 'documentY' => $this->number($anchor['documentY'] ?? null, -1000000.0, 1000000.0), 'viewportWidth' => $this->number($anchor['viewportWidth'] ?? null, 0.0, 100000.0), 'viewportHeight' => $this->number($anchor['viewportHeight'] ?? null, 0.0, 100000.0), 'scrollX' => $this->number($anchor['scrollX'] ?? null, 0.0, 1000000.0), 'scrollY' => $this->number($anchor['scrollY'] ?? null, 0.0, 1000000.0), 'devicePixelRatio' => $this->number($anchor['devicePixelRatio'] ?? null, 0.1, 10.0), 'zoomEstimate' => $this->number($anchor['zoomEstimate'] ?? null, 0.1, 10.0), ); } private function sanitise_anchor_string(mixed $value, int $max_length): string { if (!is_scalar($value)) { return ''; } $value = wp_check_invalid_utf8((string) $value); $value = preg_replace('/[<>\x00-\x1F\x7F]/u', '', $value) ?? ''; $value = preg_replace('/[^\p{L}\p{N}\s_\-#.:>+~\[\]=*"\'\^$|(),\\\\]/u', '', $value) ?? ''; return $this->truncate(trim($value), $max_length); } private function number(mixed $value, float $min, float $max): ?float { if (!is_numeric($value)) { return null; } $number = (float) $value; if (is_nan($number) || is_infinite($number) || $number < $min || $number > $max) { return null; } return $number; } /** * @param array $row * @return array */ private function page_output(array $row): array { return array( 'id' => (int) $row['id'], 'url' => (string) $row['canonical_url'], 'title' => (string) $row['page_title'], 'createdAt' => (string) $row['created_at'], 'updatedAt' => (string) $row['updated_at'], ); } /** * @param array $row * @return array */ private function comment_output(array $row): array { return array( 'id' => (int) $row['id'], 'commentNumber' => (int) ($row['comment_number'] ?? $row['id']), 'pageId' => (int) $row['page_id'], 'authorSessionId' => (int) $row['author_session_id'], 'url' => (string) $row['canonical_url'], 'pageTitle' => (string) $row['page_title'], 'authorName' => (string) $row['author_name'], 'status' => (string) $row['status'], 'anchor' => $this->decode_anchor((string) $row['anchor_json']), 'bodyHtml' => (string) $row['body_html'], 'body' => wp_strip_all_tags((string) $row['body_html']), 'screenshotState' => (string) $row['screenshot_state'], 'createdAt' => (string) $row['created_at'], 'updatedAt' => (string) $row['updated_at'], 'resolvedAt' => $row['resolved_at'] ? (string) $row['resolved_at'] : null, ); } /** * @param array $row * @return array */ private function message_output(array $row): array { return array( 'id' => (int) $row['id'], 'commentId' => (int) $row['comment_id'], 'authorSessionId' => null === $row['author_session_id'] ? null : (int) $row['author_session_id'], 'authorUserId' => null === $row['author_user_id'] ? null : (int) $row['author_user_id'], 'authorName' => (string) ($row['author_name'] ?? __('Guest', 'commentmate')), 'bodyHtml' => (string) $row['body_html'], 'body' => wp_strip_all_tags((string) $row['body_html']), 'createdAt' => (string) $row['created_at'], 'editedAt' => $row['edited_at'] ? (string) $row['edited_at'] : null, ); } /** * Keeps the database session identifier inside this service while giving the * browser only the ownership bit it needs to enable its own edit/delete/move * controls. The server remains the authority for every mutation. * * @param list> $comments * @return list> */ private function with_session_ownership(array $comments, int $session_id): array { foreach ($comments as &$comment) { $comment = $this->output_for_session($comment, $session_id); } unset($comment); return $comments; } /** * @param array $comment * @return array */ private function output_for_session(array $comment, int $session_id): array { $comment['isOwn'] = $session_id > 0 && $session_id === (int) ($comment['authorSessionId'] ?? 0); unset($comment['authorSessionId']); if (is_array($comment['messages'] ?? null)) { foreach ($comment['messages'] as &$message) { if (!is_array($message)) { continue; } $message['isOwn'] = $session_id > 0 && $session_id === (int) ($message['authorSessionId'] ?? 0); unset($message['authorSessionId'], $message['authorUserId']); } unset($message); } return $comment; } /** * @param array $message * @return array */ private function output_message_for_session(array $message, int $session_id): array { $message['isOwn'] = $session_id > 0 && $session_id === (int) ($message['authorSessionId'] ?? 0); unset($message['authorSessionId'], $message['authorUserId']); return $message; } /** * @return array */ private function decode_anchor(string $json): array { $anchor = json_decode($json, true); return is_array($anchor) ? $anchor : array(); } /** * @return array */ private function params(\WP_REST_Request $request): array { $params = $request->get_json_params(); return is_array($params) ? $params : $request->get_params(); } private function truncate(string $value, int $length): string { if (function_exists('mb_substr')) { return mb_substr($value, 0, $length); } return substr($value, 0, $length); } /** * Reserves capacity while the caller already holds the shared review/session * lifecycle lock. Soft-deleted feedback intentionally counts until the owner * deletes the whole review, so delete-and-recreate loops cannot evade the * cap. Expired idempotency replay bodies are safely discarded first. * * @return true|\WP_Error */ private function reserve_feedback_capacity(int $review_id, int $additional_bytes, int $additional_items): bool|\WP_Error { if ($review_id < 1 || $additional_bytes < 0 || $additional_items < 1) { return Security::error('feedback_quota_unavailable', __('CommentMate could not verify feedback storage capacity. Please try again.', 'commentmate'), 503); } global $wpdb; $idempotency = Database::table('commentmate_idempotency'); if (false === $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('DELETE FROM %i WHERE review_id = %d AND expires_at <= UTC_TIMESTAMP()', $idempotency, $review_id) )) { return Security::error('feedback_quota_unavailable', __('CommentMate could not verify feedback storage capacity. Please try again.', 'commentmate'), 503); } $usage = $this->feedback_quota_usage($review_id); if (!is_array($usage)) { return Security::error('feedback_quota_unavailable', __('CommentMate could not verify feedback storage capacity. Please try again.', 'commentmate'), 503); } if ($usage['bytes'] + $additional_bytes > self::MAX_REVIEW_FEEDBACK_BYTES || $usage['items'] + $additional_items > self::MAX_REVIEW_FEEDBACK_ITEMS) { return Security::error('review_feedback_limit_reached', __('This CommentMate review has reached its feedback storage limit. Ask the owner to delete this review data before collecting more feedback.', 'commentmate'), 413); } return true; } /** Owner-screen capacity uses exactly the same counts as the mutation guard. */ public function capacity(int $review_id): ?array { $usage = $review_id > 0 ? $this->feedback_quota_usage($review_id) : array('bytes' => 0, 'items' => 0); return is_array($usage) ? $usage + array('byteLimit' => self::MAX_REVIEW_FEEDBACK_BYTES, 'itemLimit' => self::MAX_REVIEW_FEEDBACK_ITEMS) : null; } /** * @return array{bytes:int,items:int}|null */ private function feedback_quota_usage(int $review_id): ?array { global $wpdb; $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $idempotency = Database::table('commentmate_idempotency'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Private feedback and per-review quota state live in plugin tables; fresh reads preserve deletion, authorization and mutation ordering. $wpdb->prepare('SELECT (SELECT COUNT(*) FROM %i AS c WHERE c.review_id = %d) AS comment_count, (SELECT COUNT(*) FROM %i AS m INNER JOIN %i AS c ON c.id = m.comment_id WHERE c.review_id = %d) AS message_count, (SELECT COALESCE(SUM(OCTET_LENGTH(c.anchor_json) + OCTET_LENGTH(c.body_html) + OCTET_LENGTH(c.author_name)), 0) FROM %i AS c WHERE c.review_id = %d) AS comment_bytes, (SELECT COALESCE(SUM(OCTET_LENGTH(m.body_html)), 0) FROM %i AS m INNER JOIN %i AS c ON c.id = m.comment_id WHERE c.review_id = %d) AS message_bytes, (SELECT COALESCE(SUM(OCTET_LENGTH(i.response_body)), 0) FROM %i AS i WHERE i.review_id = %d) AS idempotency_bytes', $comments, $review_id, $messages, $comments, $review_id, $comments, $review_id, $messages, $comments, $review_id, $idempotency, $review_id), ARRAY_A ); if (!is_array($row)) { return null; } $bytes = max(0, (int) ($row['comment_bytes'] ?? 0)) + max(0, (int) ($row['message_bytes'] ?? 0)) + max(0, (int) ($row['idempotency_bytes'] ?? 0)); $items = max(0, (int) ($row['comment_count'] ?? 0)) + max(0, (int) ($row['message_count'] ?? 0)); return array('bytes' => $bytes, 'items' => $items); } /** * Starts an InnoDB transaction and locks current review/session state before * a mutation. See Session_Service::lock_current_for_mutation(). */ private function begin_mutation(array $session): bool { global $wpdb; if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return false; } if (!Session_Service::lock_current_for_mutation($session)) { $this->rollback_mutation(); return false; } return true; } private function rollback_mutation(): void { global $wpdb; $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } private function commit_mutation(): bool { global $wpdb; return false !== $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } }