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 / step-booking-form-template / class-wpbc-setup-wizard-booking-form-template-save-handler.php

class-wpbc-setup-wizard-booking-form-template-save-handler.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-booking-form-template/class-wpbc-setup-wizard-booking-form-template-save-handler.php

464 lines 22.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Progressive save handler for the Setup Wizard Booking Form step.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Apply one allow-listed template to the Standard Booking Form.
14 *
15 * The owner-scoped Standard form is the idempotent Form Builder identity.
16 * Repeated requests therefore replace the same published row, while retained
17 * Guided Appointment additionally assigns the form to its saved Services.
18 * Other scheduled and full-day journeys intentionally keep the form independent
19 * of Appointment Services, because their routes do not contain a Services step.
20 */
21 final class WPBC_Setup_Wizard_Booking_Form_Template_Save_Handler implements WPBC_Setup_Wizard_Step_Save_Handler {
22
23 /** Canonical Form Builder identity updated by this Setup Wizard boundary. */
24 const CANONICAL_FORM_SLUG = 'standard';
25
26 /** @var WPBC_Setup_Wizard_Booking_Form_Templates */
27 private $templates;
28
29 /** @var WPBC_Setup_Wizard_Booking_Form_Time_Options */
30 private $time_options;
31
32 /**
33 * Build the handler around the server-owned template registry.
34 *
35 * @param WPBC_Setup_Wizard_Booking_Form_Templates|null $templates Optional registry adapter for tests.
36 * @param WPBC_Setup_Wizard_Booking_Form_Time_Options|null $time_options Optional time projector for tests.
37 */
38 public function __construct( $templates = null, $time_options = null ) {
39 $this->templates = $templates instanceof WPBC_Setup_Wizard_Booking_Form_Templates
40 ? $templates
41 : new WPBC_Setup_Wizard_Booking_Form_Templates();
42 $this->time_options = $time_options instanceof WPBC_Setup_Wizard_Booking_Form_Time_Options
43 ? $time_options
44 : new WPBC_Setup_Wizard_Booking_Form_Time_Options();
45 }
46
47 /**
48 * Return the owned step identifier.
49 *
50 * @return string Stable step ID.
51 */
52 public function get_step_id() {
53 return 'booking_form_template';
54 }
55
56 /**
57 * Describe whether the boundary creates or updates the Standard form.
58 *
59 * @param array<string,mixed> $checkpoint Current checkpoint.
60 * @param array<string,mixed> $step_context Authorized presentation context.
61 *
62 * @return string Translated action label.
63 */
64 public function get_primary_action_label( array $checkpoint, array $step_context = array() ) {
65 unset( $checkpoint, $step_context );
66
67 $owner_user_id = $this->get_owner_user_id();
68 $existing_form = function_exists( 'wpbc_form_config_load' )
69 ? wpbc_form_config_load( self::CANONICAL_FORM_SLUG, $owner_user_id, 'published', false )
70 : null;
71
72 return is_array( $existing_form )
73 ? __( 'Update booking form & continue', 'booking' )
74 : __( 'Create booking form & continue', 'booking' );
75 }
76
77 /**
78 * Revalidate and persist the selected form and any required Service assignments.
79 *
80 * @param array<string,mixed> $validated_fields Validated current-step fields.
81 * @param array<string,mixed> $checkpoint Current checkpoint.
82 * @param array<string,mixed> $operation_context Stable operation metadata.
83 *
84 * @return array<string,mixed>|WP_Error Verified result or error.
85 */
86 public function save( array $validated_fields, array $checkpoint, array $operation_context ) {
87 unset( $operation_context );
88
89 $customer_journey_id = $this->get_customer_journey_id( $checkpoint );
90 $is_guided_journey = 'guided_appointment_flow' === $customer_journey_id;
91 if ( ! WPBC_Setup_Wizard_Customer_Journey_Policy::uses_booking_form_and_appearance_route( $customer_journey_id ) ) {
92 return new WP_Error( 'wpbc_setup_wizard_booking_form_journey_invalid', __( 'A Booking Form cannot be created for the selected customer journey.', 'booking' ) );
93 }
94 $manage_capability = function_exists( 'wpbc_bfb_get_manage_cap' ) ? wpbc_bfb_get_manage_cap() : 'manage_options';
95 if (
96 ! current_user_can( $manage_capability )
97 || ! function_exists( 'wpbc_form_config_load' )
98 || ! function_exists( 'wpbc_form_config_save' )
99 || ! class_exists( 'WPBC_BFB_Form_Storage' )
100 ) {
101 return new WP_Error( 'wpbc_setup_wizard_booking_form_forbidden', __( 'You are not allowed to create or update this Booking Form.', 'booking' ) );
102 }
103 if ( $is_guided_journey && ! function_exists( 'wpbc_appointment_services_get_data_provider' ) ) {
104 return new WP_Error( 'wpbc_setup_wizard_booking_form_storage_unsupported', __( 'The configured Appointment Services storage does not support Booking Form assignment from the Setup Wizard.', 'booking' ) );
105 }
106
107 $template_slug = isset( $validated_fields['booking_form_template'] )
108 ? $this->templates->validate_template_slug( $validated_fields['booking_form_template'], true )
109 : new WP_Error( 'wpbc_setup_wizard_booking_form_template_required', __( 'Choose a booking form template.', 'booking' ) );
110 if ( is_wp_error( $template_slug ) ) {
111 return $template_slug;
112 }
113 $booking_form_usage = isset( $validated_fields['booking_form_usage'] )
114 ? $this->templates->validate_booking_form_usage_for_journey( $validated_fields['booking_form_usage'], $customer_journey_id, true )
115 : new WP_Error( 'wpbc_setup_wizard_booking_form_usage_required', __( 'Choose how customers will open the booking form.', 'booking' ) );
116 if ( is_wp_error( $booking_form_usage ) ) {
117 return $booking_form_usage;
118 }
119
120 $template_form_config = $this->templates->get_save_form_config( $template_slug );
121 if ( is_wp_error( $template_form_config ) ) {
122 return $template_form_config;
123 }
124 $wizard_values = isset( $checkpoint['values'] ) && is_array( $checkpoint['values'] ) ? $checkpoint['values'] : array();
125 $template_form_config = $this->time_options->apply_to_form_config(
126 $template_form_config,
127 $this->time_options->get_context_from_values( $wizard_values )
128 );
129 if ( is_wp_error( $template_form_config ) ) {
130 return $template_form_config;
131 }
132
133 $owner_user_id = $this->get_owner_user_id();
134 $existing_form = wpbc_form_config_load( self::CANONICAL_FORM_SLUG, $owner_user_id, 'published', false );
135 $form_config = $this->build_standard_form_config( $template_form_config, $existing_form, $owner_user_id );
136 $service_ids = array();
137 $repository = null;
138 $service_before_images = array();
139 if ( $is_guided_journey ) {
140 $service_ids = $this->get_service_ids( $checkpoint );
141 if ( empty( $service_ids ) ) {
142 return new WP_Error( 'wpbc_setup_wizard_booking_form_services_missing', __( 'Save at least one Appointment Service before creating the Booking Form.', 'booking' ) );
143 }
144
145 $repository = wpbc_appointment_services_get_data_provider();
146 if (
147 ! is_object( $repository )
148 || ! method_exists( $repository, 'get_deletion_before_image' )
149 || ! method_exists( $repository, 'update_booking_form_id' )
150 || ! method_exists( $repository, 'find' )
151 ) {
152 return new WP_Error( 'wpbc_setup_wizard_booking_form_storage_unsupported', __( 'The configured Appointment Services storage does not support Booking Form assignment from the Setup Wizard.', 'booking' ) );
153 }
154
155 foreach ( $service_ids as $service_id ) {
156 $before_image = $repository->get_deletion_before_image( $service_id );
157 if ( is_wp_error( $before_image ) || ! $this->is_current_owner_service( $before_image ) ) {
158 return new WP_Error( 'wpbc_setup_wizard_booking_form_service_stale', __( 'A saved Service changed or is no longer available. Return to Services and review it before continuing.', 'booking' ) );
159 }
160 $service_before_images[ $service_id ] = $before_image;
161 }
162 }
163
164 $form_was_written = false;
165 if ( is_array( $existing_form ) && $this->form_config_matches( $form_config, $existing_form ) ) {
166 $booking_form_id = absint( $existing_form['id'] );
167 } else {
168 $booking_form_id = wpbc_form_config_save( $form_config, array( 'sync_legacy' => false ) );
169 $form_was_written = true;
170 if ( ! $booking_form_id ) {
171 return new WP_Error( 'wpbc_setup_wizard_booking_form_not_saved', __( 'The Booking Form could not be saved.', 'booking' ) );
172 }
173 }
174
175 foreach ( $service_before_images as $service_id => $before_image ) {
176 if ( absint( $before_image['service']['booking_form_id'] ) === absint( $booking_form_id ) ) {
177 continue;
178 }
179 $saved_service = $repository->update_booking_form_id( $service_id, $booking_form_id );
180 if ( is_wp_error( $saved_service ) ) {
181 $was_restored = $this->compensate( $repository, $existing_form, $form_config, $booking_form_id, $service_before_images, $form_was_written );
182 $message = $was_restored
183 ? __( 'The Booking Form could not be assigned to every Service. Previous values were restored.', 'booking' )
184 : __( 'The Booking Form could not be assigned to every Service, and previous values could not be restored completely. Review Booking Forms and Services before continuing.', 'booking' );
185 return new WP_Error( 'wpbc_setup_wizard_booking_form_assignment_failed', $message );
186 }
187 }
188
189 $verified_form = wpbc_form_config_load( self::CANONICAL_FORM_SLUG, $owner_user_id, 'published', false );
190 if (
191 ! is_array( $verified_form )
192 || absint( $verified_form['id'] ) !== absint( $booking_form_id )
193 || ! $this->form_config_matches( $form_config, $verified_form )
194 ) {
195 $was_restored = $this->compensate( $repository, $existing_form, $form_config, $booking_form_id, $service_before_images, $form_was_written );
196 $message = $was_restored
197 ? __( 'The Booking Form could not be verified. Previous values were restored.', 'booking' )
198 : __( 'The Booking Form could not be verified, and previous values could not be restored completely. Review Booking Forms and Services before continuing.', 'booking' );
199 return new WP_Error( 'wpbc_setup_wizard_booking_form_not_verified', $message );
200 }
201 foreach ( $service_ids as $service_id ) {
202 $verified_service = $repository->find( $service_id );
203 if ( is_wp_error( $verified_service ) || absint( $verified_service['booking_form_id'] ) !== absint( $booking_form_id ) ) {
204 $was_restored = $this->compensate( $repository, $existing_form, $form_config, $booking_form_id, $service_before_images, $form_was_written );
205 $message = $was_restored
206 ? __( 'The Booking Form assignment could not be verified. Previous values were restored.', 'booking' )
207 : __( 'The Booking Form assignment could not be verified, and previous values could not be restored completely. Review Booking Forms and Services before continuing.', 'booking' );
208 return new WP_Error( 'wpbc_setup_wizard_booking_form_assignment_not_verified', $message );
209 }
210 }
211
212 if ( $is_guided_journey ) {
213 $summary = is_array( $existing_form )
214 ? __( 'Standard Booking Form updated and assigned to Services.', 'booking' )
215 : __( 'Standard Booking Form created and assigned to Services.', 'booking' );
216 } else {
217 $summary = is_array( $existing_form )
218 ? __( 'Standard Booking Form updated.', 'booking' )
219 : __( 'Standard Booking Form created.', 'booking' );
220 }
221
222 return array(
223 'status' => 'saved',
224 'summary' => $summary,
225 'canonical_fingerprint' => hash( 'sha256', wp_json_encode( array( 'form' => $verified_form, 'source_template' => $template_slug, 'service_ids' => $service_ids, 'usage' => $booking_form_usage ) ) ),
226 'created_ids' => is_array( $existing_form ) ? array() : array( 'booking_form:' . self::CANONICAL_FORM_SLUG . ':' . absint( $booking_form_id ) ),
227 'updated_ids' => is_array( $existing_form ) ? array( 'booking_form:' . self::CANONICAL_FORM_SLUG . ':' . absint( $booking_form_id ) ) : array(),
228 'warnings' => array(),
229 'writes_canonical_settings' => true,
230 );
231 }
232
233 /**
234 * Retarget a registered template payload to the canonical Standard form.
235 *
236 * Applying a template in the Form Builder replaces the current form's field
237 * structure and settings without changing that form's identity or its
238 * administrator-facing details. The Setup Wizard follows the same contract:
239 * template content is copied into `standard`, while an existing Standard
240 * title, description, and picture remain owned by the Form Details editor.
241 *
242 * @param array<string,mixed> $template_form_config Server-owned template payload.
243 * @param array<string,mixed>|null $existing_form Existing Standard FormConfig, if any.
244 * @param int $owner_user_id Effective MultiUser owner ID.
245 *
246 * @return array<string,mixed> Canonical Standard FormConfig ready for persistence.
247 */
248 private function build_standard_form_config( array $template_form_config, $existing_form, $owner_user_id ) {
249 $existing_form = is_array( $existing_form ) ? $existing_form : array();
250
251 $template_form_config['form_name'] = self::CANONICAL_FORM_SLUG;
252 $template_form_config['owner_user_id'] = absint( $owner_user_id );
253 $template_form_config['scope'] = $owner_user_id ? 'user' : 'global';
254 $template_form_config['status'] = 'published';
255 $template_form_config['is_default'] = 1;
256 $template_form_config['booking_resource_id'] = null;
257 $template_form_config['title'] = array_key_exists( 'title', $existing_form )
258 ? (string) $existing_form['title']
259 : __( 'Standard', 'booking' );
260 $template_form_config['description'] = array_key_exists( 'description', $existing_form )
261 ? (string) $existing_form['description']
262 : '';
263 $template_form_config['picture_url'] = array_key_exists( 'picture_url', $existing_form )
264 ? (string) $existing_form['picture_url']
265 : '';
266
267 return $template_form_config;
268 }
269
270 /**
271 * Restore form and Service before-images after a partial cross-domain write.
272 *
273 * @param WPBC_Appointment_Services_Repository|null $repository Canonical Services repository when assignments exist.
274 * @param array<string,mixed>|null $existing_form Original FormConfig, or null for a created form.
275 * @param array<string,mixed> $attempted_form Attempted FormConfig.
276 * @param int $booking_form_id Attempted canonical form ID.
277 * @param array<int,array<string,mixed>> $service_before_images Original Service records.
278 * @param bool $form_was_written Whether this request changed FormConfig storage.
279 *
280 * @return bool True when every Service and form compensation write succeeds.
281 */
282 private function compensate( $repository, $existing_form, array $attempted_form, $booking_form_id, array $service_before_images, $form_was_written ) {
283 $was_restored = true;
284 if ( ! empty( $service_before_images ) && ! is_object( $repository ) ) {
285 return false;
286 }
287 foreach ( $service_before_images as $before_image ) {
288 $service_id = absint( $before_image['service']['service_id'] );
289 $previous_booking_form_id = absint( $before_image['service']['booking_form_id'] );
290 $restored_service = $repository->update_booking_form_id( $service_id, $previous_booking_form_id );
291 if ( is_wp_error( $restored_service ) || absint( $restored_service['booking_form_id'] ) !== $previous_booking_form_id ) {
292 $was_restored = false;
293 }
294 }
295 if ( ! $form_was_written ) {
296 return $was_restored;
297 }
298
299 if ( is_array( $existing_form ) ) {
300 $restored_form_id = wpbc_form_config_save( $existing_form, array( 'sync_legacy' => false ) );
301 $restored_form = wpbc_form_config_load( $existing_form['form_name'], $existing_form['owner_user_id'], $existing_form['status'], false );
302 if ( ! $restored_form_id || ! is_array( $restored_form ) || ! $this->form_config_matches( $existing_form, $restored_form ) ) {
303 $was_restored = false;
304 }
305
306 return $was_restored;
307 }
308
309 if ( ! WPBC_BFB_Form_Storage::delete_form_if_matches(
310 $booking_form_id,
311 $attempted_form['form_name'],
312 $attempted_form['status'],
313 $attempted_form['owner_user_id']
314 ) ) {
315 $was_restored = false;
316 } elseif ( is_array( wpbc_form_config_load( $attempted_form['form_name'], $attempted_form['owner_user_id'], $attempted_form['status'], false ) ) ) {
317 $was_restored = false;
318 }
319
320 return $was_restored;
321 }
322
323 /**
324 * Compare the stable FormConfig fields owned by the selected template.
325 *
326 * Storage timestamps and the generated numeric ID are intentionally excluded.
327 * Form-style settings are global Appearance options and are stripped by the
328 * canonical Form Builder writer before the record is persisted.
329 *
330 * @param array<string,mixed> $expected Expected server-owned FormConfig.
331 * @param array<string,mixed> $actual Canonical FormConfig readback.
332 *
333 * @return bool True when every persisted template field matches.
334 */
335 private function form_config_matches( array $expected, array $actual ) {
336 $expected_settings = isset( $expected['settings'] ) && is_array( $expected['settings'] ) ? $expected['settings'] : array();
337 if ( function_exists( 'wpbc_bfb_settings__strip_form_style_options_from_form_settings' ) ) {
338 $expected_settings = wpbc_bfb_settings__strip_form_style_options_from_form_settings( $expected_settings );
339 }
340
341 $expected_values = array(
342 'form_name' => isset( $expected['form_name'] ) ? (string) $expected['form_name'] : '',
343 'owner_user_id' => isset( $expected['owner_user_id'] ) ? absint( $expected['owner_user_id'] ) : 0,
344 'engine' => isset( $expected['engine'] ) ? (string) $expected['engine'] : 'bfb',
345 'engine_version' => isset( $expected['engine_version'] ) ? (string) $expected['engine_version'] : '1.0',
346 'title' => isset( $expected['title'] ) ? (string) $expected['title'] : '',
347 'description' => isset( $expected['description'] ) ? (string) $expected['description'] : '',
348 'scope' => isset( $expected['scope'] ) ? (string) $expected['scope'] : 'global',
349 'status' => isset( $expected['status'] ) ? (string) $expected['status'] : 'published',
350 'is_default' => ! empty( $expected['is_default'] ) ? 1 : 0,
351 'booking_resource_id' => isset( $expected['booking_resource_id'] ) ? absint( $expected['booking_resource_id'] ) : null,
352 'structure_json' => isset( $expected['structure_json'] ) ? (string) $expected['structure_json'] : '',
353 'settings' => $expected_settings,
354 'advanced_form' => isset( $expected['advanced_form'] ) ? (string) $expected['advanced_form'] : '',
355 'content_form' => isset( $expected['content_form'] ) ? (string) $expected['content_form'] : '',
356 'picture_url' => isset( $expected['picture_url'] ) ? (string) $expected['picture_url'] : '',
357 );
358 $actual_values = array(
359 'form_name' => isset( $actual['form_name'] ) ? (string) $actual['form_name'] : '',
360 'owner_user_id' => isset( $actual['owner_user_id'] ) ? absint( $actual['owner_user_id'] ) : 0,
361 'engine' => isset( $actual['engine'] ) ? (string) $actual['engine'] : 'bfb',
362 'engine_version' => isset( $actual['engine_version'] ) ? (string) $actual['engine_version'] : '1.0',
363 'title' => isset( $actual['title'] ) ? (string) $actual['title'] : '',
364 'description' => isset( $actual['description'] ) ? (string) $actual['description'] : '',
365 'scope' => isset( $actual['scope'] ) ? (string) $actual['scope'] : 'global',
366 'status' => isset( $actual['status'] ) ? (string) $actual['status'] : 'published',
367 'is_default' => ! empty( $actual['is_default'] ) ? 1 : 0,
368 'booking_resource_id' => isset( $actual['booking_resource_id'] ) ? absint( $actual['booking_resource_id'] ) : null,
369 'structure_json' => isset( $actual['structure_json'] ) ? (string) $actual['structure_json'] : '',
370 'settings' => isset( $actual['settings'] ) && is_array( $actual['settings'] ) ? $actual['settings'] : array(),
371 'advanced_form' => isset( $actual['advanced_form'] ) ? (string) $actual['advanced_form'] : '',
372 'content_form' => isset( $actual['content_form'] ) ? (string) $actual['content_form'] : '',
373 'picture_url' => isset( $actual['picture_url'] ) ? (string) $actual['picture_url'] : '',
374 );
375
376 return $expected_values === $actual_values;
377 }
378
379 /**
380 * Resolve stable Service IDs from the saved draft and retained result map.
381 *
382 * @param array<string,mixed> $checkpoint Current checkpoint.
383 *
384 * @return int[] Owner-visible Service IDs.
385 */
386 private function get_service_ids( array $checkpoint ) {
387 $result = isset( $checkpoint['step_results']['services'] ) && is_array( $checkpoint['step_results']['services'] ) ? $checkpoint['step_results']['services'] : array();
388 $stored = array_merge( isset( $result['created_ids'] ) ? (array) $result['created_ids'] : array(), isset( $result['updated_ids'] ) ? (array) $result['updated_ids'] : array() );
389 $mappings = array();
390 foreach ( $stored as $mapping ) {
391 if ( is_scalar( $mapping ) && preg_match( '/^service:([a-z0-9_-]{1,64}):([0-9]+)$/', (string) $mapping, $matches ) ) {
392 $mappings[ $matches[1] ] = absint( $matches[2] );
393 }
394 }
395
396 $service_ids = array();
397 $services = isset( $checkpoint['values']['services']['services'] ) && is_array( $checkpoint['values']['services']['services'] )
398 ? $checkpoint['values']['services']['services']
399 : array();
400 foreach ( $services as $service ) {
401 $draft_id = isset( $service['draft_id'] ) ? sanitize_key( (string) $service['draft_id'] ) : '';
402 $service_id = isset( $service['source_service_id'] ) ? absint( $service['source_service_id'] ) : 0;
403 if ( ! $service_id && isset( $mappings[ $draft_id ] ) ) {
404 $service_id = absint( $mappings[ $draft_id ] );
405 }
406 if ( $service_id ) {
407 $service_ids[] = $service_id;
408 }
409 }
410
411 return array_values( array_unique( $service_ids ) );
412 }
413
414 /**
415 * Resolve the current Form Builder owner without trusting request data.
416 *
417 * @return int Current MultiUser owner ID, or zero for global scope.
418 */
419 private function get_owner_user_id() {
420 if ( function_exists( 'wpbc_appointment_services_get_owner_user_id' ) ) {
421 return absint( wpbc_appointment_services_get_owner_user_id() );
422 }
423
424 return class_exists( 'WPBC_FE_Custom_Form_Helper' ) && method_exists( 'WPBC_FE_Custom_Form_Helper', 'wpbc_mu__get_current__owner_user_id' )
425 ? absint( WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id() )
426 : 0;
427 }
428
429 /**
430 * Confirm that one assignment target belongs to the FormConfig owner.
431 *
432 * @param array<string,mixed> $before_image Canonical Service before-image.
433 *
434 * @return bool True when the Service and Booking Form share one owner scope.
435 */
436 private function is_current_owner_service( array $before_image ) {
437 $service = isset( $before_image['service'] ) && is_array( $before_image['service'] ) ? $before_image['service'] : array();
438
439 return isset( $service['owner_user_id'] ) && $this->get_owner_user_id() === absint( $service['owner_user_id'] );
440 }
441
442 /**
443 * Read the saved Customer Journey.
444 *
445 * @param array<string,mixed> $checkpoint Current checkpoint.
446 *
447 * @return string Journey ID.
448 */
449 private function get_customer_journey_id( array $checkpoint ) {
450 return isset( $checkpoint['values']['customer_journey']['customer_journey'] )
451 ? sanitize_key( (string) $checkpoint['values']['customer_journey']['customer_journey'] )
452 : '';
453 }
454
455 /**
456 * Booking Form is not the terminal route step.
457 *
458 * @return bool False.
459 */
460 public function is_terminal() {
461 return false;
462 }
463 }
464