<?php
/**
* File containing the model \Crowdsignal_Forms\Synchronization\Poll_Block_Synchronizer.
*
* @package crowdsignal-forms/Synchronization
* @since 1.0.0
*/
namespace Crowdsignal_Forms\Synchronization;
use Crowdsignal_Forms\Crowdsignal_Forms;
use Crowdsignal_Forms\Models\Poll;
use Crowdsignal_Forms\Gateways\Post_Poll_Meta_Gateway;
use \WP_Block_Type_Registry;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class Poll_Block_Synchronizer
*
* @package Crowdsignal_Forms\Synchronization
*/
class Poll_Block_Synchronizer {
/**
* The authenticator object.
*
* @since 1.0.0
* @var \Crowdsignal_Forms\Auth\Crowdsignal_Forms_Api_Authenticator|null
*/
protected $authenticator;
/**
* The api gateway.
*
* @since 1.0.0
* @var \Crowdsignal_Forms\Gateways\Api_Gateway_Interface
*/
protected $gateway;
/**
* The entity we are saving the sync result to.
*
* @since 1.0.0
* @var Synchronizable_Entity;
*/
protected $entity_bridge;
/**
* Poll_Block_Synchronizer constructor.
*
* @since 1.0.0
* @param Synchronizable_Entity $sync_entity The entity which will persist the sync results.
*/
public function __construct( $sync_entity ) {
$this->entity_bridge = $sync_entity;
}
/**
* Save function.
*
* @return bool|void
* @throws \Exception Thrown in cases where severe failures happen.
*/
public function synchronize() {
if ( ! $this->entity_bridge->can_be_saved() ) {
return;
}
$this->authenticator = Crowdsignal_Forms::instance()->get_api_authenticator();
// if there aren't any CS blocks AND a user code hasn't been requested yet, then there is nothing to sync.
if ( ! $this->entity_bridge->has_crowdsignal_forms_blocks()
&& ! $this->authenticator->has_user_code() ) {
return;
}
if ( ! $this->authenticator->get_user_code() ) {
// Plugin hasn't been authenticated yet, abort sync.
return;
}
$poll_ids_saved_in_entity = $this->entity_bridge->get_poll_ids_saved_in_entity();
if ( ! $this->entity_bridge->has_crowdsignal_forms_blocks() ) {
// No poll blocks, proactively archive any polls that were previously saved.
$this->archive_polls_with_ids( $poll_ids_saved_in_entity );
if ( ! empty( $poll_ids_saved_in_entity ) ) {
$this->entity_bridge->update_poll_ids_present_in_entity( array() );
}
return;
}
$this->gateway = Crowdsignal_Forms::instance()->get_api_gateway();
$blocks = $this->entity_bridge->get_blocks();
try {
$poll_ids_present_in_content = $this->process_blocks( $blocks );
$poll_ids_to_archive = array_diff( $poll_ids_saved_in_entity, $poll_ids_present_in_content );
$this->archive_polls_with_ids( $poll_ids_to_archive );
$this->entity_bridge->update_poll_ids_present_in_entity( $poll_ids_present_in_content );
} catch ( \Exception $sync_exception ) {
$this->handle_api_sync_exception( $sync_exception );
return;
}
return true;
}
/**
* Archive polls with these ids.
*
* @param array $poll_ids_to_archive Ids to archive.
*/
protected function archive_polls_with_ids( $poll_ids_to_archive ) {
if ( empty( $poll_ids_to_archive ) ) {
return;
}
$this->gateway = Crowdsignal_Forms::instance()->get_api_gateway();
foreach ( $poll_ids_to_archive as $id_to_archive ) {
$this->gateway->archive_poll( $id_to_archive );
}
}
/**
* Processes all blocks in the content to find any poll blocks that need to be saved.
*
* @param array $blocks List of blocks to check.
*
* @return array
*
* @throws \Exception In case of bad request. This is temporary.
* @since 1.0.0
*/
protected function process_blocks( $blocks ) {
// search for all poll blocks at top level and nested in other blocks.
$poll_blocks = array();
$blocks_to_process = $blocks;
$poll_ids_present_in_content = array();
while ( ! empty( $blocks_to_process ) ) {
$blocks_to_process_next_iteration = array();
foreach ( $blocks_to_process as $block ) {
if ( in_array( $block['blockName'], array( 'crowdsignal-forms/poll', 'crowdsignal-forms/vote', 'crowdsignal-forms/applause' ), true ) ) {
if ( empty( $block['attrs']['pollId'] ) ) {
// this means somehow a newly created poll still not saved, let it there to maybe sync next time.
continue;
}
$poll_blocks[] = $block;
continue;
}
if ( empty( $block['innerBlocks'] ) ) {
continue;
}
$blocks_to_process_next_iteration = array_merge( $blocks_to_process_next_iteration, $block['innerBlocks'] );
}
$blocks_to_process = $blocks_to_process_next_iteration;
}
$post_poll_meta_gateway = Crowdsignal_Forms::instance()->get_post_poll_meta_gateway();
// process the found blocks.
foreach ( $poll_blocks as $poll_block ) {
$poll_client_id = $poll_block['attrs']['pollId'];
if ( empty( $poll_client_id ) ) {
// This is sorta serious, means the poll block is invalid, what to do?
// for now, throw!
throw new \Exception( 'No poll client_id' );
}
$platform_poll_data = $this->entity_bridge->get_entity_poll_data( $poll_client_id );
// Security check: Verify user can edit copied polls.
// If this poll client_id is mapped to a different post/comment (copy/paste scenario),
// the user must have edit permission on the original post or comment.
$current_post_id = isset( $platform_poll_data['post_id'] ) ? (int) $platform_poll_data['post_id'] : 0;
$current_comment_id = isset( $platform_poll_data['comment_id'] ) ? (int) $platform_poll_data['comment_id'] : 0;
if ( $current_post_id > 0 && ! $this->can_sync_poll( $poll_client_id, $current_post_id, $current_comment_id, $post_poll_meta_gateway ) ) {
// User doesn't have permission to edit the original post/comment - skip syncing this poll.
// The poll remains in content but won't be updated on the platform.
continue;
}
$poll = Poll::from_array( $platform_poll_data );
$this->apply_block_attribute_defaults( $poll_block );
$poll->update_from_block( $poll_block );
if ( $poll->get_id() < 1 ) {
$result = $this->gateway->create_poll( $poll );
} else {
$result = $this->gateway->update_poll( $poll );
}
if ( ! is_wp_error( $result ) ) {
$poll_ids_present_in_content[] = $result->get_id();
$this->entity_bridge->update_entity_poll_data( $poll_client_id, $result->to_array() );
} else {
// TODO: Pretty serious, we didn't get a poll response. What to do? Throw!
throw new \Exception( esc_html( $result->get_error_code() ) );
}
}
return $poll_ids_present_in_content;
}
/**
* Sets block attribute default values on the provided block object.
*
* @param array $block
* @return void
*/
private function apply_block_attribute_defaults( &$block ) {
$block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
$default_attributes = $block_type->attributes;
foreach ( $default_attributes as $attribute_name => $attribute ) {
if ( ! isset( $block['attrs'][ $attribute_name ] ) ) {
// phpcs:ignore Generic.Formatting.MultipleStatementAlignment.NotSameWarning
$default = isset( $attribute['default'] ) ? $attribute['default'] : null;
$block['attrs'][ $attribute_name ] = $default;
}
}
}
/**
* Fire any exception handling code here.
*
* @param \Exception $sync_exception The sync exception.
*/
private function handle_api_sync_exception( $sync_exception ) {
/**
* Sync failed for some reason. We might want to do something about this by hooking into this action.
*
* @param \Exception $sync_exception The exception that was thrown.
* @param Poll_Block_Synchronizer $this This block sync instance.
* @since 1.0.0
*/
do_action( 'crowdsignal_forms_poll_sync_exception', $sync_exception, $this );
}
/**
* Check if the current user can sync a poll.
*
* If the poll client_id is mapped to a different post/comment (copy/paste scenario),
* the user must have edit permission on the original post or comment.
*
* All 4 copy/paste scenarios:
* - Post → Post: require edit_post on original post
* - Post → Comment: require edit_post on original post
* - Comment → Post: require edit_comment on original comment
* - Comment → Comment: require edit_comment on original comment
*
* @since 1.8.0
*
* @param string $poll_client_id The poll client ID.
* @param int $current_post_id The current post ID being saved.
* @param int $current_comment_id The current comment ID (0 if saving a post).
* @param Post_Poll_Meta_Gateway $post_poll_meta_gateway The meta gateway instance.
* @return bool True if the user can sync this poll.
*/
private function can_sync_poll( $poll_client_id, $current_post_id, $current_comment_id, $post_poll_meta_gateway ) {
// Get the original location (post and/or comment) for this client_id.
$original_location = $post_poll_meta_gateway->get_original_location_for_client_id( $poll_client_id );
$original_post_id = $original_location['post_id'];
if ( null === $original_post_id ) {
// Not mapped yet - this is a new poll, allow it.
return true;
}
// Check if the poll is being edited in its original location.
$original_comment_id = $original_location['comment_id'];
$is_original_location = false;
if ( $original_comment_id ) {
// Original was in a comment.
$is_original_location = ( $current_comment_id === $original_comment_id );
} else {
// Original was in a post.
$is_original_location = ( $current_post_id === $original_post_id && 0 === $current_comment_id );
}
if ( $is_original_location ) {
// Same location - this is the original poll, allow it.
return true;
}
// Poll is mapped to a different location - check appropriate permission.
if ( $original_comment_id ) {
// Poll originated in a comment - require edit_comment on original comment.
if ( ! current_user_can( 'edit_comment', $original_comment_id ) ) {
return false;
}
} else {
// Poll originated in a post - require edit_post on original post.
if ( ! current_user_can( 'edit_post', $original_post_id ) ) {
return false;
}
}
return true;
}
}