PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-setup-wizard / class-wpbc-setup-wizard-draft-store.php

class-wpbc-setup-wizard-draft-store.php in Booking Calendar 11.9, at includes/page-setup-wizard/class-wpbc-setup-wizard-draft-store.php

911 lines 39.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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