| 1 |
<?php |
| 2 |
/** |
| 3 |
* User-scoped checkpoint persistence for the isolated Setup Wizard module. |
| 4 |
* |
| 5 |
* @package Booking Calendar |
| 6 |
*/ |
| 7 |
|
| 8 |
if ( ! defined( 'ABSPATH' ) ) { |
| 9 |
exit; |
| 10 |
} |
| 11 |
|
| 12 |
/** |
| 13 |
* Persist validated wizard progress without owning domain mutations. |
| 14 |
*/ |
| 15 |
final class WPBC_Setup_Wizard_Draft_Store implements WPBC_Setup_Wizard_Checkpoint_Store, WPBC_Setup_Wizard_Email_State_Store { |
| 16 |
|
| 17 |
/** Current normalized checkpoint schema. */ |
| 18 |
const SCHEMA_VERSION = 21; |
| 19 |
|
| 20 |
/** @var WPBC_Setup_Wizard_Step_Registry */ |
| 21 |
private $step_registry; |
| 22 |
|
| 23 |
/** @var WPBC_Setup_Wizard_Draft_Validator */ |
| 24 |
private $draft_validator; |
| 25 |
|
| 26 |
/** @var array{real_user_id:int,owner_user_id:int,site_id:int} */ |
| 27 |
private $storage_context; |
| 28 |
|
| 29 |
/** |
| 30 |
* Build a draft store bound to the current server-owned user and site context. |
| 31 |
* |
| 32 |
* @param WPBC_Setup_Wizard_Step_Registry $step_registry Ordered step registry. |
| 33 |
* @param WPBC_Setup_Wizard_Draft_Validator $draft_validator Draft field validator. |
| 34 |
*/ |
| 35 |
public function __construct( WPBC_Setup_Wizard_Step_Registry $step_registry, WPBC_Setup_Wizard_Draft_Validator $draft_validator ) { |
| 36 |
$this->step_registry = $step_registry; |
| 37 |
$this->draft_validator = $draft_validator; |
| 38 |
$this->storage_context = WPBC_Setup_Wizard_Access::get_storage_context(); |
| 39 |
} |
| 40 |
|
| 41 |
/** |
| 42 |
* Return the context-specific user-option key. |
| 43 |
* |
| 44 |
* @return string User-option key isolated by site and effective owner. |
| 45 |
*/ |
| 46 |
public function get_option_key() { |
| 47 |
return sprintf( |
| 48 |
'booking_setup_wizard_11_9_draft_%1$d_%2$d', |
| 49 |
$this->storage_context['site_id'], |
| 50 |
$this->storage_context['owner_user_id'] |
| 51 |
); |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Return a clean in-memory checkpoint without writing storage. |
| 56 |
* |
| 57 |
* @return array<string,mixed> Default navigation draft. |
| 58 |
*/ |
| 59 |
public function get_default_draft() { |
| 60 |
$email_status = WPBC_Setup_Wizard_Environment_Policy::allows_summary_email() |
| 61 |
? 'not_requested' |
| 62 |
: 'disabled'; |
| 63 |
|
| 64 |
return array( |
| 65 |
'schema_version' => self::SCHEMA_VERSION, |
| 66 |
'status' => 'active', |
| 67 |
'current_step' => $this->step_registry->get_first_step_id(), |
| 68 |
'completed_steps' => array(), |
| 69 |
'needs_review_steps' => array(), |
| 70 |
'values' => array(), |
| 71 |
'step_results' => array(), |
| 72 |
'revision' => 0, |
| 73 |
'updated_at' => '', |
| 74 |
'skipped_at' => '', |
| 75 |
'completed_at' => '', |
| 76 |
'email' => array( |
| 77 |
'requested' => false, |
| 78 |
'recipient' => '', |
| 79 |
'status' => $email_status, |
| 80 |
'operation_id' => '', |
| 81 |
'summary_fingerprint' => '', |
| 82 |
'attempted_at' => '', |
| 83 |
'sent_at' => '', |
| 84 |
'last_error' => '', |
| 85 |
), |
| 86 |
); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Load and normalize the current draft without causing a write. |
| 91 |
* |
| 92 |
* @return array<string,mixed> Normalized navigation draft. |
| 93 |
*/ |
| 94 |
public function load() { |
| 95 |
$stored_draft = get_user_option( $this->get_option_key(), $this->storage_context['real_user_id'] ); |
| 96 |
|
| 97 |
return $this->normalize_draft( is_array( $stored_draft ) ? $stored_draft : array() ); |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Return an opaque lock scope for this site, real user, and effective owner. |
| 102 |
* |
| 103 |
* @return string Context-specific lock scope. |
| 104 |
*/ |
| 105 |
public function get_lock_scope() { |
| 106 |
return sprintf( |
| 107 |
'%1$d:%2$d:%3$d', |
| 108 |
$this->storage_context['site_id'], |
| 109 |
$this->storage_context['real_user_id'], |
| 110 |
$this->storage_context['owner_user_id'] |
| 111 |
); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Return the active server-owned route for one checkpoint. |
| 116 |
* |
| 117 |
* @param array<string,mixed> $checkpoint Current normalized checkpoint. |
| 118 |
* |
| 119 |
* @return string[] Ordered active step identifiers. |
| 120 |
*/ |
| 121 |
public function get_active_step_ids( array $checkpoint ) { |
| 122 |
return $this->step_registry->get_active_step_ids( |
| 123 |
isset( $checkpoint['values'] ) && is_array( $checkpoint['values'] ) ? $checkpoint['values'] : array() |
| 124 |
); |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Navigate backward without validating or merging current form fields. |
| 129 |
* |
| 130 |
* Back is deliberately navigation-only. A prior successful save remains |
| 131 |
* active and unsaved edits on the current page are discarded. |
| 132 |
* |
| 133 |
* @param string $client_step_id Client-observed current step identifier. |
| 134 |
* @param int $expected_revision Client-observed checkpoint revision. |
| 135 |
* |
| 136 |
* @return array<string,mixed>|WP_Error Updated checkpoint or error. |
| 137 |
*/ |
| 138 |
public function navigate_back( $client_step_id, $expected_revision ) { |
| 139 |
$current_draft = $this->load(); |
| 140 |
$stale_error = $this->validate_request_state( $current_draft, $client_step_id, $expected_revision ); |
| 141 |
|
| 142 |
if ( is_wp_error( $stale_error ) ) { |
| 143 |
return $stale_error; |
| 144 |
} |
| 145 |
|
| 146 |
$target_step_id = $this->step_registry->get_adjacent_step_id( $current_draft['current_step'], 'back', $current_draft['values'] ); |
| 147 |
if ( null === $target_step_id ) { |
| 148 |
return new WP_Error( 'wpbc_setup_wizard_navigation_boundary', __( 'There is no available Setup Wizard step in that direction.', 'booking' ) ); |
| 149 |
} |
| 150 |
|
| 151 |
$current_draft['current_step'] = $target_step_id; |
| 152 |
$current_draft['status'] = 'active'; |
| 153 |
$current_draft['completed_at'] = ''; |
| 154 |
$current_draft['completed_steps'] = $this->step_registry->get_completed_step_ids( $current_draft['current_step'], $current_draft['values'] ); |
| 155 |
$current_draft['revision'] = (int) $current_draft['revision'] + 1; |
| 156 |
$current_draft['updated_at'] = current_time( 'mysql', true ); |
| 157 |
|
| 158 |
return $this->save( $current_draft ); |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Commit one validated save result and advance to the next route step. |
| 163 |
* |
| 164 |
* @param string $client_step_id Step being saved. |
| 165 |
* @param int $expected_revision Client-observed revision. |
| 166 |
* @param array<string,mixed> $validated_fields Validated step fields. |
| 167 |
* @param string $operation_id Stable idempotency identifier. |
| 168 |
* @param array<string,mixed> $operation_result Normalized handler result. |
| 169 |
* @param bool $is_terminal Whether this completes the route. |
| 170 |
* |
| 171 |
* @return array<string,mixed>|WP_Error Updated checkpoint or error. |
| 172 |
*/ |
| 173 |
public function commit_step_save( $client_step_id, $expected_revision, array $validated_fields, $operation_id, array $operation_result, $is_terminal ) { |
| 174 |
$current_draft = $this->load(); |
| 175 |
$stale_error = $this->validate_request_state( $current_draft, $client_step_id, $expected_revision ); |
| 176 |
if ( is_wp_error( $stale_error ) ) { |
| 177 |
return $stale_error; |
| 178 |
} |
| 179 |
|
| 180 |
$current_values = isset( $current_draft['values'][ $client_step_id ] ) && is_array( $current_draft['values'][ $client_step_id ] ) |
| 181 |
? $current_draft['values'][ $client_step_id ] |
| 182 |
: array(); |
| 183 |
$merged_values = array_merge( $current_values, $validated_fields ); |
| 184 |
$values_changed = $current_values !== $merged_values; |
| 185 |
$current_draft['values'][ $client_step_id ] = $merged_values; |
| 186 |
|
| 187 |
$saved_at = current_time( 'mysql', true ); |
| 188 |
$current_draft['step_results'][ $client_step_id ] = array_merge( |
| 189 |
$operation_result, |
| 190 |
array( |
| 191 |
'operation_id' => sanitize_key( (string) $operation_id ), |
| 192 |
'source_revision' => (int) $expected_revision, |
| 193 |
'saved_at' => $saved_at, |
| 194 |
'submitted_values' => $validated_fields, |
| 195 |
) |
| 196 |
); |
| 197 |
$active_step_ids = $this->step_registry->get_active_step_ids( $current_draft['values'] ); |
| 198 |
$registered_step_ids = array_keys( $this->step_registry->get_steps() ); |
| 199 |
$current_index = array_search( $client_step_id, $registered_step_ids, true ); |
| 200 |
$needs_review = array_values( array_diff( $current_draft['needs_review_steps'], array( $client_step_id ) ) ); |
| 201 |
if ( $values_changed && false !== $current_index ) { |
| 202 |
foreach ( array_slice( $registered_step_ids, $current_index + 1 ) as $dependent_step_id ) { |
| 203 |
if ( isset( $current_draft['step_results'][ $dependent_step_id ] ) ) { |
| 204 |
$needs_review[] = $dependent_step_id; |
| 205 |
} |
| 206 |
} |
| 207 |
} |
| 208 |
$current_draft['needs_review_steps'] = array_values( array_unique( $needs_review ) ); |
| 209 |
|
| 210 |
if ( $is_terminal ) { |
| 211 |
$current_draft['status'] = 'completed'; |
| 212 |
$current_draft['completed_steps'] = $active_step_ids; |
| 213 |
$current_draft['completed_at'] = $saved_at; |
| 214 |
} else { |
| 215 |
$target_step_id = $this->step_registry->get_adjacent_step_id( $client_step_id, 'next', $current_draft['values'] ); |
| 216 |
if ( null === $target_step_id ) { |
| 217 |
return new WP_Error( 'wpbc_setup_wizard_navigation_boundary', __( 'There is no available Setup Wizard step in that direction.', 'booking' ) ); |
| 218 |
} |
| 219 |
|
| 220 |
$current_draft['status'] = 'active'; |
| 221 |
$current_draft['current_step'] = $target_step_id; |
| 222 |
$current_draft['completed_steps'] = $this->step_registry->get_completed_step_ids( $target_step_id, $current_draft['values'] ); |
| 223 |
$current_draft['completed_at'] = ''; |
| 224 |
} |
| 225 |
|
| 226 |
$current_draft['revision'] = (int) $current_draft['revision'] + 1; |
| 227 |
$current_draft['updated_at'] = $saved_at; |
| 228 |
|
| 229 |
return $this->save( $current_draft ); |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Navigate to one earlier active Setup Wizard step selected by the server. |
| 234 |
* |
| 235 |
* The target must be part of the current route and precede the current step. |
| 236 |
* This lets the journey rail and review actions revisit completed steps without |
| 237 |
* allowing a client to open inactive branch pages or skip forward. |
| 238 |
* |
| 239 |
* @param string $target_step_id Requested earlier active-route target. |
| 240 |
* @param string $client_step_id Client-observed current step. |
| 241 |
* @param int $expected_revision Client-observed draft revision. |
| 242 |
* |
| 243 |
* @return array<string,mixed>|WP_Error Updated draft or a validation/storage error. |
| 244 |
*/ |
| 245 |
public function navigate_to_step( $target_step_id, $client_step_id, $expected_revision ) { |
| 246 |
$current_draft = $this->load(); |
| 247 |
$stale_error = $this->validate_request_state( $current_draft, $client_step_id, $expected_revision ); |
| 248 |
|
| 249 |
if ( is_wp_error( $stale_error ) ) { |
| 250 |
return $stale_error; |
| 251 |
} |
| 252 |
|
| 253 |
$target_step_id = sanitize_key( is_scalar( $target_step_id ) ? (string) $target_step_id : '' ); |
| 254 |
$active_steps = $this->step_registry->get_active_step_ids( $current_draft['values'] ); |
| 255 |
$current_step_index = array_search( $current_draft['current_step'], $active_steps, true ); |
| 256 |
$target_step_index = array_search( $target_step_id, $active_steps, true ); |
| 257 |
|
| 258 |
if ( false === $current_step_index || false === $target_step_index || $target_step_index >= $current_step_index ) { |
| 259 |
return new WP_Error( 'wpbc_setup_wizard_edit_target_invalid', __( 'That setup step is not available as a completed step in this journey.', 'booking' ) ); |
| 260 |
} |
| 261 |
|
| 262 |
$current_draft['current_step'] = $target_step_id; |
| 263 |
$current_draft['status'] = 'active'; |
| 264 |
$current_draft['completed_at'] = ''; |
| 265 |
$current_draft['completed_steps'] = $this->step_registry->get_completed_step_ids( $target_step_id, $current_draft['values'] ); |
| 266 |
$current_draft['revision'] = (int) $current_draft['revision'] + 1; |
| 267 |
$current_draft['updated_at'] = current_time( 'mysql', true ); |
| 268 |
|
| 269 |
return $this->save( $current_draft ); |
| 270 |
} |
| 271 |
|
| 272 |
/** |
| 273 |
* Restart wizard navigation and recommendations without rolling back settings. |
| 274 |
* |
| 275 |
* Released Setup Wizard completion flags and all canonical settings remain |
| 276 |
* untouched. The reset starts a new recommendation/navigation pass while |
| 277 |
* preserving operation evidence, email history, and a monotonic revision. |
| 278 |
* |
| 279 |
* @param int $expected_revision Client-observed draft revision. |
| 280 |
* |
| 281 |
* @return array<string,mixed>|WP_Error Restarted draft or a validation/storage error. |
| 282 |
*/ |
| 283 |
public function restart( $expected_revision ) { |
| 284 |
$current_draft = $this->load(); |
| 285 |
$stale_error = $this->validate_request_state( $current_draft, $current_draft['current_step'], $expected_revision ); |
| 286 |
|
| 287 |
if ( is_wp_error( $stale_error ) ) { |
| 288 |
return $stale_error; |
| 289 |
} |
| 290 |
|
| 291 |
$restarted_draft = $this->get_default_draft(); |
| 292 |
$restarted_draft['step_results'] = $current_draft['step_results']; |
| 293 |
$restarted_draft['needs_review_steps'] = array_keys( $current_draft['step_results'] ); |
| 294 |
$restarted_draft['email'] = $current_draft['email']; |
| 295 |
$restarted_draft['revision'] = (int) $current_draft['revision'] + 1; |
| 296 |
$restarted_draft['updated_at'] = current_time( 'mysql', true ); |
| 297 |
|
| 298 |
return $this->save( $restarted_draft ); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Complete the wizard while explicitly skipping every remaining active step. |
| 303 |
* |
| 304 |
* This transition does not validate or save fields from the current page and |
| 305 |
* does not perform domain writes. Canonical changes saved by earlier steps |
| 306 |
* remain active, while the checkpoint becomes terminal so the normal Setup |
| 307 |
* overview is shown on the next request. |
| 308 |
* |
| 309 |
* @param string $client_step_id Client-observed current step identifier. |
| 310 |
* @param int $expected_revision Client-observed draft revision. |
| 311 |
* |
| 312 |
* @return array<string,mixed>|WP_Error Completed checkpoint or a validation/storage error. |
| 313 |
*/ |
| 314 |
public function complete_with_skipped_steps( $client_step_id, $expected_revision ) { |
| 315 |
$current_draft = $this->load(); |
| 316 |
$stale_error = $this->validate_request_state( $current_draft, $client_step_id, $expected_revision ); |
| 317 |
|
| 318 |
if ( is_wp_error( $stale_error ) ) { |
| 319 |
return $stale_error; |
| 320 |
} |
| 321 |
|
| 322 |
return $this->save( $this->prepare_skipped_completion( $current_draft ) ); |
| 323 |
} |
| 324 |
|
| 325 |
/** |
| 326 |
* Build the terminal checkpoint used by Skip Setup Wizard. |
| 327 |
* |
| 328 |
* The active route is server-owned and may vary by environment and selected |
| 329 |
* Customer Journey. Resolving that route here prevents the browser from |
| 330 |
* claiming completion for inactive branch steps. |
| 331 |
* |
| 332 |
* @param array<string,mixed> $current_draft Current normalized checkpoint. |
| 333 |
* |
| 334 |
* @return array<string,mixed> Terminal checkpoint ready for persistence. |
| 335 |
*/ |
| 336 |
private function prepare_skipped_completion( array $current_draft ) { |
| 337 |
$completed_at = current_time( 'mysql', true ); |
| 338 |
$active_step_ids = $this->step_registry->get_active_step_ids( $current_draft['values'] ); |
| 339 |
|
| 340 |
$current_draft['status'] = 'completed'; |
| 341 |
$current_draft['current_step'] = (string) end( $active_step_ids ); |
| 342 |
$current_draft['completed_steps'] = $active_step_ids; |
| 343 |
$current_draft['needs_review_steps'] = array(); |
| 344 |
$current_draft['skipped_at'] = $completed_at; |
| 345 |
$current_draft['completed_at'] = $completed_at; |
| 346 |
$current_draft['updated_at'] = $completed_at; |
| 347 |
$current_draft['revision'] = (int) $current_draft['revision'] + 1; |
| 348 |
|
| 349 |
return $current_draft; |
| 350 |
} |
| 351 |
|
| 352 |
/** |
| 353 |
* Persist Review-page summary-email metadata against an expected revision. |
| 354 |
* |
| 355 |
* Email delivery is intentionally owned by the Review module. This method is |
| 356 |
* only the user-, site-, and owner-scoped persistence boundary required to |
| 357 |
* record pending, sent, or failed delivery without exposing the complete |
| 358 |
* checkpoint write implementation. |
| 359 |
* |
| 360 |
* @param array<string,mixed> $email_state New summary-email metadata. |
| 361 |
* @param int $expected_revision Checkpoint revision observed by the sender. |
| 362 |
* |
| 363 |
* @return array<string,mixed>|WP_Error Updated checkpoint or a stale/storage error. |
| 364 |
*/ |
| 365 |
public function update_email_state( array $email_state, $expected_revision ) { |
| 366 |
$current_draft = $this->load(); |
| 367 |
if ( (int) $current_draft['revision'] !== (int) $expected_revision ) { |
| 368 |
return new WP_Error( 'wpbc_setup_wizard_email_stale_revision', __( 'The Setup Wizard state changed in another request. Reload the page and try again.', 'booking' ) ); |
| 369 |
} |
| 370 |
|
| 371 |
$current_draft['email'] = $this->normalize_email_state( $email_state ); |
| 372 |
$current_draft['revision'] = (int) $current_draft['revision'] + 1; |
| 373 |
$current_draft['updated_at'] = current_time( 'mysql', true ); |
| 374 |
|
| 375 |
return $this->save( $current_draft ); |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* Normalize stored draft fields against server-owned contracts. |
| 380 |
* |
| 381 |
* @param array<string,mixed> $stored_draft Stored draft candidate. |
| 382 |
* |
| 383 |
* @return array<string,mixed> Normalized draft. |
| 384 |
*/ |
| 385 |
private function normalize_draft( array $stored_draft ) { |
| 386 |
$default_draft = $this->get_default_draft(); |
| 387 |
$stored_schema_version = isset( $stored_draft['schema_version'] ) && is_scalar( $stored_draft['schema_version'] ) |
| 388 |
? absint( $stored_draft['schema_version'] ) |
| 389 |
: 0; |
| 390 |
$normalized_values = $this->draft_validator->normalize_stored_values( |
| 391 |
isset( $stored_draft['values'] ) && is_array( $stored_draft['values'] ) ? $stored_draft['values'] : array(), |
| 392 |
array_keys( $this->step_registry->get_steps() ) |
| 393 |
); |
| 394 |
$stored_current_step = isset( $stored_draft['current_step'] ) && is_scalar( $stored_draft['current_step'] ) |
| 395 |
? sanitize_key( (string) $stored_draft['current_step'] ) |
| 396 |
: ''; |
| 397 |
$stored_journey = isset( $normalized_values['customer_journey']['customer_journey'] ) |
| 398 |
? (string) $normalized_values['customer_journey']['customer_journey'] |
| 399 |
: ''; |
| 400 |
|
| 401 |
/* |
| 402 |
* Schema 4 placed Days Off before Guided Services and Working Hours. Move |
| 403 |
* an unfinished legacy draft to its first missing prerequisite so the |
| 404 |
* reordered route cannot be bypassed after an upgrade. |
| 405 |
*/ |
| 406 |
if ( 5 > $stored_schema_version && 'guided_appointment_flow' === $stored_journey && 'days_off' === $stored_current_step ) { |
| 407 |
if ( empty( $normalized_values['services'] ) ) { |
| 408 |
$stored_current_step = 'services'; |
| 409 |
} elseif ( empty( $normalized_values['working_hours'] ) ) { |
| 410 |
$stored_current_step = 'working_hours'; |
| 411 |
} |
| 412 |
} |
| 413 |
|
| 414 |
/* |
| 415 |
* Schema 6 placed Appearance before Booking Form. Route an unfinished |
| 416 |
* Guided draft to its first missing prerequisite after the order changed. |
| 417 |
*/ |
| 418 |
if ( |
| 419 |
7 > $stored_schema_version |
| 420 |
&& 'guided_appointment_flow' === $stored_journey |
| 421 |
&& 'appearance' === $stored_current_step |
| 422 |
&& empty( $normalized_values['booking_form_template'] ) |
| 423 |
) { |
| 424 |
$stored_current_step = 'booking_form_template'; |
| 425 |
} |
| 426 |
|
| 427 |
/* |
| 428 |
* Schema 8 inserts Date Selection immediately after Customer Journey. |
| 429 |
* Return unfinished drafts that had already passed that boundary to the |
| 430 |
* new prerequisite once, without overwriting any previously saved values. |
| 431 |
*/ |
| 432 |
if ( |
| 433 |
8 > $stored_schema_version |
| 434 |
&& empty( $normalized_values['date_selection'] ) |
| 435 |
&& in_array( $stored_current_step, array( 'services', 'working_hours', 'days_off', 'booking_form_template', 'appearance' ), true ) |
| 436 |
) { |
| 437 |
$stored_current_step = 'date_selection'; |
| 438 |
} |
| 439 |
|
| 440 |
/* |
| 441 |
* Schema 9 appends the draft-only Publish & integrate page. Existing |
| 442 |
* unfinished drafts stay on their current step and reach it naturally. |
| 443 |
*/ |
| 444 |
|
| 445 |
/* |
| 446 |
* Schema 10 appends Review Setup. Existing drafts retain their current |
| 447 |
* page and reach the new revalidation boundary through normal navigation. |
| 448 |
*/ |
| 449 |
|
| 450 |
/* |
| 451 |
* Schema 11 introduces progressive-save metadata. A schema 10 `reviewed` |
| 452 |
* flag represented draft approval only, so it must not be promoted to a |
| 453 |
* completed or applied checkpoint. Preserve validated values and require |
| 454 |
* those previously visited steps to be reviewed at the new save boundary. |
| 455 |
*/ |
| 456 |
|
| 457 |
/* |
| 458 |
* Schema 12 records a Review-summary fingerprint and attempt timestamp. |
| 459 |
* Older normalized email fields remain valid; the first eligible Review |
| 460 |
* opening creates the new idempotency metadata without migrating options. |
| 461 |
*/ |
| 462 |
|
| 463 |
/* |
| 464 |
* Schema 14 removes Services from the `start_end_time` route. An unfinished |
| 465 |
* schema-13 draft left on Services moves to Start & End Times. Older drafts |
| 466 |
* that already passed this new prerequisite return there only when no time |
| 467 |
* choices exist. Completed setups remain completed and open Setup Overview. |
| 468 |
*/ |
| 469 |
if ( |
| 470 |
14 > $stored_schema_version |
| 471 |
&& 'start_end_time' === $stored_journey |
| 472 |
&& 'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 473 |
&& ( |
| 474 |
'services' === $stored_current_step |
| 475 |
|| ( |
| 476 |
empty( $normalized_values['start_end_times'] ) |
| 477 |
&& in_array( $stored_current_step, array( 'days_off', 'publish_integration', 'review_setup' ), true ) |
| 478 |
) |
| 479 |
) |
| 480 |
) { |
| 481 |
$stored_current_step = 'start_end_times'; |
| 482 |
} |
| 483 |
|
| 484 |
/* |
| 485 |
* Schema 15 gives every time-configured journey the complete scheduling, |
| 486 |
* form, appearance, publishing, and review route. Move an unfinished older |
| 487 |
* checkpoint to its first missing prerequisite only when it had already |
| 488 |
* reached or passed that prerequisite. Saved values are never replaced. |
| 489 |
*/ |
| 490 |
if ( |
| 491 |
15 > $stored_schema_version |
| 492 |
&& WPBC_Setup_Wizard_Customer_Journey_Policy::uses_time_configuration( $stored_journey ) |
| 493 |
&& 'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 494 |
) { |
| 495 |
$prerequisites_by_current_step = array( |
| 496 |
'services' => array( 'start_end_times' ), |
| 497 |
'working_hours' => array( 'start_end_times' ), |
| 498 |
'days_off' => array( 'start_end_times', 'working_hours' ), |
| 499 |
'booking_form_template' => array( 'start_end_times', 'working_hours' ), |
| 500 |
'appearance' => array( 'start_end_times', 'working_hours', 'booking_form_template' ), |
| 501 |
'publish_integration' => array( 'start_end_times', 'working_hours', 'booking_form_template', 'appearance' ), |
| 502 |
'review_setup' => array( 'start_end_times', 'working_hours', 'booking_form_template', 'appearance' ), |
| 503 |
); |
| 504 |
$required_steps = isset( $prerequisites_by_current_step[ $stored_current_step ] ) |
| 505 |
? $prerequisites_by_current_step[ $stored_current_step ] |
| 506 |
: array(); |
| 507 |
foreach ( $required_steps as $required_step_id ) { |
| 508 |
if ( empty( $normalized_values[ $required_step_id ] ) ) { |
| 509 |
$stored_current_step = $required_step_id; |
| 510 |
break; |
| 511 |
} |
| 512 |
} |
| 513 |
} |
| 514 |
|
| 515 |
/* |
| 516 |
* Schema 16 separates Start Time + Duration from the former shared |
| 517 |
* Start/End checkpoint. Preserve any validated start-time ordering from |
| 518 |
* that legacy proposal, seed only the previously missing duration list, |
| 519 |
* and return unfinished drafts that passed the boundary to the new step. |
| 520 |
* Completed setups remain completed and continue to Setup Overview. |
| 521 |
*/ |
| 522 |
if ( 16 > $stored_schema_version && WPBC_Setup_Wizard_Customer_Journey_Policy::uses_start_duration_configuration( $stored_journey ) ) { |
| 523 |
if ( empty( $normalized_values['start_duration_times']['start_duration_times'] ) ) { |
| 524 |
$start_duration_service = new WPBC_Setup_Wizard_Start_Duration_Times(); |
| 525 |
$migrated_time_choices = $start_duration_service->get_initial_start_duration_times(); |
| 526 |
$legacy_start_times = isset( $normalized_values['start_end_times']['start_end_times']['start_times'] ) |
| 527 |
? $normalized_values['start_end_times']['start_end_times']['start_times'] |
| 528 |
: array(); |
| 529 |
$legacy_proposal = $start_duration_service->validate_start_duration_times( |
| 530 |
array( |
| 531 |
'start_times' => $legacy_start_times, |
| 532 |
'duration_times' => $migrated_time_choices['duration_times'], |
| 533 |
), |
| 534 |
true |
| 535 |
); |
| 536 |
if ( ! is_wp_error( $legacy_proposal ) ) { |
| 537 |
$migrated_time_choices = $legacy_proposal; |
| 538 |
} |
| 539 |
$normalized_values['start_duration_times'] = array( |
| 540 |
'start_duration_times' => $migrated_time_choices, |
| 541 |
); |
| 542 |
} |
| 543 |
|
| 544 |
if ( |
| 545 |
'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 546 |
&& in_array( $stored_current_step, array( 'start_end_times', 'working_hours', 'days_off', 'booking_form_template', 'appearance', 'publish_integration', 'review_setup' ), true ) |
| 547 |
) { |
| 548 |
$stored_current_step = 'start_duration_times'; |
| 549 |
} |
| 550 |
} |
| 551 |
|
| 552 |
/* |
| 553 |
* Schema 17 separates Fixed Time Slots from the former independent |
| 554 |
* Start/End proposal. Convert equal-length valid legacy lists to explicit |
| 555 |
* slot pairs; otherwise use the preferred template's safe defaults. Return |
| 556 |
* unfinished drafts that passed the boundary to the new dedicated step. |
| 557 |
*/ |
| 558 |
if ( 17 > $stored_schema_version && WPBC_Setup_Wizard_Customer_Journey_Policy::uses_fixed_time_slots_configuration( $stored_journey ) ) { |
| 559 |
if ( empty( $normalized_values['fixed_time_slots']['fixed_time_slots'] ) ) { |
| 560 |
$fixed_slots_service = new WPBC_Setup_Wizard_Fixed_Time_Slots(); |
| 561 |
$migrated_slots = $fixed_slots_service->get_initial_fixed_time_slots(); |
| 562 |
$legacy_start_times = isset( $normalized_values['start_end_times']['start_end_times']['start_times'] ) |
| 563 |
? $normalized_values['start_end_times']['start_end_times']['start_times'] |
| 564 |
: array(); |
| 565 |
$legacy_end_times = isset( $normalized_values['start_end_times']['start_end_times']['end_times'] ) |
| 566 |
? $normalized_values['start_end_times']['start_end_times']['end_times'] |
| 567 |
: array(); |
| 568 |
$legacy_slots = $fixed_slots_service->create_slots_from_time_lists( $legacy_start_times, $legacy_end_times ); |
| 569 |
if ( ! is_wp_error( $legacy_slots ) ) { |
| 570 |
$migrated_slots = $legacy_slots; |
| 571 |
} |
| 572 |
$normalized_values['fixed_time_slots'] = array( |
| 573 |
'fixed_time_slots' => $migrated_slots, |
| 574 |
); |
| 575 |
} |
| 576 |
|
| 577 |
if ( |
| 578 |
'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 579 |
&& in_array( $stored_current_step, array( 'start_end_times', 'working_hours', 'days_off', 'booking_form_template', 'appearance', 'publish_integration', 'review_setup' ), true ) |
| 580 |
) { |
| 581 |
$stored_current_step = 'fixed_time_slots'; |
| 582 |
} |
| 583 |
} |
| 584 |
|
| 585 |
/* |
| 586 |
* Schema 18 inserts Booking Resources after Date Selection for Single Full |
| 587 |
* Day. Return unfinished drafts that already passed this boundary to the new |
| 588 |
* prerequisite. Completed setup remains completed and existing canonical |
| 589 |
* Resources are never duplicated or changed by migration. |
| 590 |
*/ |
| 591 |
if ( |
| 592 |
18 > $stored_schema_version |
| 593 |
&& 'single_full_day' === $stored_journey |
| 594 |
&& 'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 595 |
&& in_array( $stored_current_step, array( 'days_off', 'publish_integration', 'review_setup' ), true ) |
| 596 |
) { |
| 597 |
$stored_current_step = 'booking_resources'; |
| 598 |
} |
| 599 |
|
| 600 |
/* |
| 601 |
* Schema 19 gives every supported full-day and date-range journey the same |
| 602 |
* complete resource route. Return unfinished checkpoints that already passed |
| 603 |
* a newly required boundary to their first missing prerequisite. Completed |
| 604 |
* setups remain completed, and saved Resource, form, and appearance values are |
| 605 |
* preserved without repeating their canonical writes. |
| 606 |
*/ |
| 607 |
if ( |
| 608 |
19 > $stored_schema_version |
| 609 |
&& WPBC_Setup_Wizard_Customer_Journey_Policy::uses_booking_resources_configuration( $stored_journey ) |
| 610 |
&& 'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 611 |
) { |
| 612 |
$prerequisites_by_current_step = array( |
| 613 |
'days_off' => array( 'booking_resources' ), |
| 614 |
'booking_form_template' => array( 'booking_resources' ), |
| 615 |
'appearance' => array( 'booking_resources', 'booking_form_template' ), |
| 616 |
'publish_integration' => array( 'booking_resources', 'booking_form_template', 'appearance' ), |
| 617 |
'review_setup' => array( 'booking_resources', 'booking_form_template', 'appearance' ), |
| 618 |
); |
| 619 |
$required_steps = isset( $prerequisites_by_current_step[ $stored_current_step ] ) |
| 620 |
? $prerequisites_by_current_step[ $stored_current_step ] |
| 621 |
: array(); |
| 622 |
|
| 623 |
foreach ( $required_steps as $required_step_id ) { |
| 624 |
if ( empty( $normalized_values[ $required_step_id ] ) ) { |
| 625 |
$stored_current_step = $required_step_id; |
| 626 |
break; |
| 627 |
} |
| 628 |
} |
| 629 |
} |
| 630 |
|
| 631 |
/* |
| 632 |
* Schema 20 gives both multi-date timed journeys the complete Start/End, |
| 633 |
* Booking Form, and Appearance route. Repeat bookings also require Working |
| 634 |
* Hours, while the First-date boundary-time route added by schema 21 does |
| 635 |
* not. Return unfinished checkpoints to their first missing prerequisite |
| 636 |
* without replacing already saved values or canonical data. |
| 637 |
*/ |
| 638 |
if ( |
| 639 |
20 > $stored_schema_version |
| 640 |
&& in_array( $stored_journey, array( 'repeated_time_multiple_dates', 'first_start_last_end' ), true ) |
| 641 |
&& 'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 642 |
) { |
| 643 |
$time_prerequisites = array( 'start_end_times' ); |
| 644 |
if ( WPBC_Setup_Wizard_Customer_Journey_Policy::uses_working_hours_configuration( $stored_journey ) ) { |
| 645 |
$time_prerequisites[] = 'working_hours'; |
| 646 |
} |
| 647 |
$prerequisites_by_current_step = array( |
| 648 |
'days_off' => $time_prerequisites, |
| 649 |
'booking_form_template' => $time_prerequisites, |
| 650 |
'appearance' => array_merge( $time_prerequisites, array( 'booking_form_template' ) ), |
| 651 |
'publish_integration' => array_merge( $time_prerequisites, array( 'booking_form_template', 'appearance' ) ), |
| 652 |
'review_setup' => array_merge( $time_prerequisites, array( 'booking_form_template', 'appearance' ) ), |
| 653 |
); |
| 654 |
$required_steps = isset( $prerequisites_by_current_step[ $stored_current_step ] ) |
| 655 |
? $prerequisites_by_current_step[ $stored_current_step ] |
| 656 |
: array(); |
| 657 |
|
| 658 |
foreach ( $required_steps as $required_step_id ) { |
| 659 |
if ( empty( $normalized_values[ $required_step_id ] ) ) { |
| 660 |
$stored_current_step = $required_step_id; |
| 661 |
break; |
| 662 |
} |
| 663 |
} |
| 664 |
} |
| 665 |
|
| 666 |
/* |
| 667 |
* Schema 21 removes Working Hours from First-date start and last-date end. |
| 668 |
* Continue an unfinished checkpoint parked on the removed page at Days Off. |
| 669 |
* Previously saved Working Hours values remain available to other journeys; |
| 670 |
* normalization does not mutate canonical availability settings. |
| 671 |
*/ |
| 672 |
if ( |
| 673 |
21 > $stored_schema_version |
| 674 |
&& 'first_start_last_end' === $stored_journey |
| 675 |
&& 'completed' !== ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) |
| 676 |
&& 'working_hours' === $stored_current_step |
| 677 |
) { |
| 678 |
$stored_current_step = 'days_off'; |
| 679 |
} |
| 680 |
|
| 681 |
$current_step_id = $this->step_registry->normalize_step_id( |
| 682 |
$stored_current_step, |
| 683 |
$normalized_values |
| 684 |
); |
| 685 |
$status = isset( $stored_draft['status'] ) && in_array( $stored_draft['status'], array( 'active', 'skipped', 'completed' ), true ) |
| 686 |
? $stored_draft['status'] |
| 687 |
: 'active'; |
| 688 |
if ( self::SCHEMA_VERSION > $stored_schema_version && 'reviewed' === ( isset( $stored_draft['status'] ) ? $stored_draft['status'] : '' ) ) { |
| 689 |
$status = 'active'; |
| 690 |
} |
| 691 |
|
| 692 |
$active_step_ids = $this->step_registry->get_active_step_ids( $normalized_values ); |
| 693 |
$registered_step_ids = array_keys( $this->step_registry->get_steps() ); |
| 694 |
$normalized_results = $this->normalize_step_results( |
| 695 |
isset( $stored_draft['step_results'] ) && is_array( $stored_draft['step_results'] ) ? $stored_draft['step_results'] : array(), |
| 696 |
$registered_step_ids |
| 697 |
); |
| 698 |
$needs_review_steps = $this->normalize_step_ids( |
| 699 |
isset( $stored_draft['needs_review_steps'] ) && is_array( $stored_draft['needs_review_steps'] ) ? $stored_draft['needs_review_steps'] : array(), |
| 700 |
$registered_step_ids |
| 701 |
); |
| 702 |
if ( self::SCHEMA_VERSION > $stored_schema_version ) { |
| 703 |
$legacy_completed_steps = $this->step_registry->get_completed_step_ids( $current_step_id, $normalized_values ); |
| 704 |
$legacy_value_steps = $this->normalize_step_ids( array_keys( $normalized_values ), $registered_step_ids ); |
| 705 |
$legacy_result_steps = $this->normalize_step_ids( array_keys( $normalized_results ), $registered_step_ids ); |
| 706 |
$needs_review_steps = array_values( array_unique( array_merge( $needs_review_steps, $legacy_completed_steps, $legacy_value_steps, $legacy_result_steps ) ) ); |
| 707 |
} |
| 708 |
|
| 709 |
$completed_at = isset( $stored_draft['completed_at'] ) && is_string( $stored_draft['completed_at'] ) |
| 710 |
? sanitize_text_field( $stored_draft['completed_at'] ) |
| 711 |
: ''; |
| 712 |
if ( 'completed' !== $status || '' === $completed_at ) { |
| 713 |
$status = 'completed' === $status ? 'active' : $status; |
| 714 |
$completed_at = ''; |
| 715 |
} |
| 716 |
$completed_steps = 'completed' === $status |
| 717 |
? $active_step_ids |
| 718 |
: $this->step_registry->get_completed_step_ids( $current_step_id, $normalized_values ); |
| 719 |
|
| 720 |
return array( |
| 721 |
'schema_version' => self::SCHEMA_VERSION, |
| 722 |
'status' => $status, |
| 723 |
'current_step' => $current_step_id, |
| 724 |
'completed_steps' => $completed_steps, |
| 725 |
'needs_review_steps' => $needs_review_steps, |
| 726 |
'values' => $normalized_values, |
| 727 |
'step_results' => $normalized_results, |
| 728 |
'revision' => isset( $stored_draft['revision'] ) ? max( 0, absint( $stored_draft['revision'] ) ) : $default_draft['revision'], |
| 729 |
'updated_at' => isset( $stored_draft['updated_at'] ) && is_string( $stored_draft['updated_at'] ) ? sanitize_text_field( $stored_draft['updated_at'] ) : '', |
| 730 |
'skipped_at' => isset( $stored_draft['skipped_at'] ) && is_string( $stored_draft['skipped_at'] ) ? sanitize_text_field( $stored_draft['skipped_at'] ) : '', |
| 731 |
'completed_at' => $completed_at, |
| 732 |
'email' => $this->normalize_email_state( isset( $stored_draft['email'] ) ? $stored_draft['email'] : array() ), |
| 733 |
); |
| 734 |
} |
| 735 |
|
| 736 |
/** |
| 737 |
* Normalize stored step identifiers against one server-owned allow-list. |
| 738 |
* |
| 739 |
* @param mixed $step_ids Stored step identifier list. |
| 740 |
* @param string[] $allowed_step_ids Allowed step identifiers. |
| 741 |
* |
| 742 |
* @return string[] Unique ordered allowed step identifiers. |
| 743 |
*/ |
| 744 |
private function normalize_step_ids( $step_ids, array $allowed_step_ids ) { |
| 745 |
$normalized_step_ids = array(); |
| 746 |
foreach ( is_array( $step_ids ) ? $step_ids : array() as $step_id ) { |
| 747 |
$step_id = sanitize_key( is_scalar( $step_id ) ? (string) $step_id : '' ); |
| 748 |
if ( in_array( $step_id, $allowed_step_ids, true ) && ! in_array( $step_id, $normalized_step_ids, true ) ) { |
| 749 |
$normalized_step_ids[] = $step_id; |
| 750 |
} |
| 751 |
} |
| 752 |
|
| 753 |
return $normalized_step_ids; |
| 754 |
} |
| 755 |
|
| 756 |
/** |
| 757 |
* Normalize bounded progressive-save results for registered steps. |
| 758 |
* |
| 759 |
* Results from an inactive route branch remain available as historical |
| 760 |
* metadata. Changing Customer Journey recommendations must not imply that a |
| 761 |
* canonical record created by a later domain handler was removed. |
| 762 |
* |
| 763 |
* @param array<string,mixed> $step_results Stored result candidates. |
| 764 |
* @param string[] $registered_step_ids Registered step identifiers. |
| 765 |
* |
| 766 |
* @return array<string,array<string,mixed>> Normalized results keyed by step. |
| 767 |
*/ |
| 768 |
private function normalize_step_results( array $step_results, array $registered_step_ids ) { |
| 769 |
$normalized_results = array(); |
| 770 |
foreach ( $registered_step_ids as $step_id ) { |
| 771 |
if ( empty( $step_results[ $step_id ] ) || ! is_array( $step_results[ $step_id ] ) ) { |
| 772 |
continue; |
| 773 |
} |
| 774 |
$step_result = $step_results[ $step_id ]; |
| 775 |
if ( 'saved' !== ( isset( $step_result['status'] ) ? $step_result['status'] : '' ) ) { |
| 776 |
continue; |
| 777 |
} |
| 778 |
$normalized_submitted_values = $this->draft_validator->normalize_stored_values( |
| 779 |
array( |
| 780 |
$step_id => isset( $step_result['submitted_values'] ) && is_array( $step_result['submitted_values'] ) |
| 781 |
? $step_result['submitted_values'] |
| 782 |
: array(), |
| 783 |
), |
| 784 |
array( $step_id ) |
| 785 |
); |
| 786 |
|
| 787 |
$normalized_results[ $step_id ] = array( |
| 788 |
'status' => 'saved', |
| 789 |
'operation_id' => isset( $step_result['operation_id'] ) ? sanitize_key( (string) $step_result['operation_id'] ) : '', |
| 790 |
'source_revision' => isset( $step_result['source_revision'] ) ? max( 0, absint( $step_result['source_revision'] ) ) : 0, |
| 791 |
'saved_at' => isset( $step_result['saved_at'] ) ? sanitize_text_field( (string) $step_result['saved_at'] ) : '', |
| 792 |
'summary' => isset( $step_result['summary'] ) ? sanitize_text_field( (string) $step_result['summary'] ) : '', |
| 793 |
'canonical_fingerprint' => isset( $step_result['canonical_fingerprint'] ) ? sanitize_text_field( (string) $step_result['canonical_fingerprint'] ) : '', |
| 794 |
'created_ids' => $this->normalize_scalar_list( isset( $step_result['created_ids'] ) ? $step_result['created_ids'] : array(), 100 ), |
| 795 |
'updated_ids' => $this->normalize_scalar_list( isset( $step_result['updated_ids'] ) ? $step_result['updated_ids'] : array(), 100 ), |
| 796 |
'warnings' => $this->normalize_scalar_list( isset( $step_result['warnings'] ) ? $step_result['warnings'] : array(), 20 ), |
| 797 |
'writes_canonical_settings' => ! empty( $step_result['writes_canonical_settings'] ), |
| 798 |
'submitted_values' => isset( $normalized_submitted_values[ $step_id ] ) ? $normalized_submitted_values[ $step_id ] : array(), |
| 799 |
); |
| 800 |
} |
| 801 |
|
| 802 |
return $normalized_results; |
| 803 |
} |
| 804 |
|
| 805 |
/** |
| 806 |
* Normalize a bounded scalar list used by checkpoint metadata. |
| 807 |
* |
| 808 |
* @param mixed $values Stored values. |
| 809 |
* @param int $limit Maximum retained records. |
| 810 |
* |
| 811 |
* @return array<int,int|string> Normalized scalar values. |
| 812 |
*/ |
| 813 |
private function normalize_scalar_list( $values, $limit ) { |
| 814 |
$normalized_values = array(); |
| 815 |
foreach ( is_array( $values ) ? array_slice( $values, 0, $limit ) : array() as $stored_value ) { |
| 816 |
if ( is_int( $stored_value ) ) { |
| 817 |
$normalized_values[] = $stored_value; |
| 818 |
} elseif ( is_scalar( $stored_value ) && '' !== trim( (string) $stored_value ) ) { |
| 819 |
$normalized_values[] = sanitize_text_field( (string) $stored_value ); |
| 820 |
} |
| 821 |
} |
| 822 |
|
| 823 |
return $normalized_values; |
| 824 |
} |
| 825 |
|
| 826 |
/** |
| 827 |
* Normalize optional final-summary email state without sending email. |
| 828 |
* |
| 829 |
* Live demos and WordPress Playground omit Business details. In those |
| 830 |
* environments any stored request, recipient, operation, or failure state is |
| 831 |
* discarded so a stale checkpoint cannot enable a later summary sender. |
| 832 |
* |
| 833 |
* @param mixed $email_state Stored email-state candidate. |
| 834 |
* |
| 835 |
* @return array<string,mixed> Normalized email metadata. |
| 836 |
*/ |
| 837 |
private function normalize_email_state( $email_state ) { |
| 838 |
if ( ! WPBC_Setup_Wizard_Environment_Policy::allows_summary_email() ) { |
| 839 |
return array( |
| 840 |
'requested' => false, |
| 841 |
'recipient' => '', |
| 842 |
'status' => 'disabled', |
| 843 |
'operation_id' => '', |
| 844 |
'summary_fingerprint' => '', |
| 845 |
'attempted_at' => '', |
| 846 |
'sent_at' => '', |
| 847 |
'last_error' => '', |
| 848 |
); |
| 849 |
} |
| 850 |
|
| 851 |
$email_state = is_array( $email_state ) ? $email_state : array(); |
| 852 |
$status = isset( $email_state['status'] ) && in_array( $email_state['status'], array( 'not_requested', 'pending', 'sent', 'failed' ), true ) |
| 853 |
? $email_state['status'] |
| 854 |
: 'not_requested'; |
| 855 |
|
| 856 |
return array( |
| 857 |
'requested' => ! empty( $email_state['requested'] ), |
| 858 |
'recipient' => isset( $email_state['recipient'] ) ? sanitize_email( (string) $email_state['recipient'] ) : '', |
| 859 |
'status' => $status, |
| 860 |
'operation_id' => isset( $email_state['operation_id'] ) ? sanitize_key( (string) $email_state['operation_id'] ) : '', |
| 861 |
'summary_fingerprint' => isset( $email_state['summary_fingerprint'] ) && is_string( $email_state['summary_fingerprint'] ) && 1 === preg_match( '/^[a-f0-9]{64}$/', $email_state['summary_fingerprint'] ) ? $email_state['summary_fingerprint'] : '', |
| 862 |
'attempted_at' => isset( $email_state['attempted_at'] ) ? sanitize_text_field( (string) $email_state['attempted_at'] ) : '', |
| 863 |
'sent_at' => isset( $email_state['sent_at'] ) ? sanitize_text_field( (string) $email_state['sent_at'] ) : '', |
| 864 |
'last_error' => isset( $email_state['last_error'] ) ? sanitize_text_field( (string) $email_state['last_error'] ) : '', |
| 865 |
); |
| 866 |
} |
| 867 |
|
| 868 |
/** |
| 869 |
* Reject stale navigation requests before any draft write. |
| 870 |
* |
| 871 |
* @param array<string,mixed> $current_draft Current normalized draft. |
| 872 |
* @param string $client_step_id Client-observed step identifier. |
| 873 |
* @param int $expected_revision Client-observed revision. |
| 874 |
* |
| 875 |
* @return true|WP_Error True when current, otherwise a stale-state error. |
| 876 |
*/ |
| 877 |
public function validate_request_state( array $current_draft, $client_step_id, $expected_revision ) { |
| 878 |
if ( (int) $current_draft['revision'] !== (int) $expected_revision ) { |
| 879 |
return new WP_Error( 'wpbc_setup_wizard_stale_revision', __( 'The Setup Wizard checkpoint changed in another request. Reload the page and try again.', 'booking' ) ); |
| 880 |
} |
| 881 |
|
| 882 |
$client_step_id = sanitize_key( is_scalar( $client_step_id ) ? (string) $client_step_id : '' ); |
| 883 |
if ( null === $this->step_registry->get_step( $client_step_id ) || $current_draft['current_step'] !== $client_step_id ) { |
| 884 |
return new WP_Error( 'wpbc_setup_wizard_stale_step', __( 'The Setup Wizard step changed in another request. Reload the page and try again.', 'booking' ) ); |
| 885 |
} |
| 886 |
|
| 887 |
return true; |
| 888 |
} |
| 889 |
|
| 890 |
/** |
| 891 |
* Persist one normalized draft and verify ambiguous WordPress false returns. |
| 892 |
* |
| 893 |
* @param array<string,mixed> $draft Draft to persist. |
| 894 |
* |
| 895 |
* @return array<string,mixed>|WP_Error Normalized saved draft or storage error. |
| 896 |
*/ |
| 897 |
private function save( array $draft ) { |
| 898 |
$draft = $this->normalize_draft( $draft ); |
| 899 |
$saved = update_user_option( $this->storage_context['real_user_id'], $this->get_option_key(), $draft ); |
| 900 |
|
| 901 |
if ( false === $saved ) { |
| 902 |
$stored_draft = get_user_option( $this->get_option_key(), $this->storage_context['real_user_id'] ); |
| 903 |
if ( ! is_array( $stored_draft ) || $draft !== $this->normalize_draft( $stored_draft ) ) { |
| 904 |
return new WP_Error( 'wpbc_setup_wizard_save_failed', __( 'The Setup Wizard checkpoint could not be saved.', 'booking' ) ); |
| 905 |
} |
| 906 |
} |
| 907 |
|
| 908 |
return $draft; |
| 909 |
} |
| 910 |
} |
| 911 |
|