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

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

466 lines 22.7 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 Setup Wizard Booking Resources.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Create new Resources and update authorized existing Resources for full-day setup.
14 *
15 * Canonical domain mutation services retain responsibility for storage, hooks,
16 * edition rules, and cache invalidation. This handler composes those mutations
17 * with effective-owner checks, stale-write protection, bounded retry recovery,
18 * and compensation when a later mutation or checkpoint write fails.
19 */
20 final class WPBC_Setup_Wizard_Booking_Resources_Save_Handler implements WPBC_Setup_Wizard_Step_Save_Handler {
21
22 const MAX_JOURNAL_OPERATIONS = 20;
23
24 /** @var WPBC_Setup_Wizard_Booking_Resources */
25 private $booking_resources;
26
27 /**
28 * Build the handler around the Resource validation service.
29 *
30 * @param WPBC_Setup_Wizard_Booking_Resources|null $booking_resources Optional service for tests.
31 */
32 public function __construct( $booking_resources = null ) {
33 $this->booking_resources = $booking_resources instanceof WPBC_Setup_Wizard_Booking_Resources ? $booking_resources : new WPBC_Setup_Wizard_Booking_Resources();
34 }
35
36 /** @return string Stable step ID. */
37 public function get_step_id() {
38 return 'booking_resources';
39 }
40
41 /**
42 * Return the server-owned action label.
43 *
44 * @param array<string,mixed> $checkpoint Current checkpoint.
45 * @param array<string,mixed> $step_context Authorized context.
46 *
47 * @return string Translated action label.
48 */
49 public function get_primary_action_label( array $checkpoint, array $step_context = array() ) {
50 unset( $checkpoint, $step_context );
51
52 return __( 'Save booking resources & continue', 'booking' );
53 }
54
55 /**
56 * Persist validated Resource updates and drafts exactly once.
57 *
58 * @param array<string,mixed> $validated_fields Validated current-step fields.
59 * @param array<string,mixed> $checkpoint Current checkpoint.
60 * @param array<string,mixed> $operation_context Stable operation metadata.
61 *
62 * @return array<string,mixed>|WP_Error Normalized result or error.
63 */
64 public function save( array $validated_fields, array $checkpoint, array $operation_context ) {
65 $journey_id = isset( $checkpoint['values']['customer_journey']['customer_journey'] )
66 ? sanitize_key( (string) $checkpoint['values']['customer_journey']['customer_journey'] )
67 : '';
68 if ( ! WPBC_Setup_Wizard_Customer_Journey_Policy::uses_booking_resources_configuration( $journey_id ) ) {
69 return new WP_Error( 'wpbc_setup_wizard_resources_route_invalid', __( 'Booking Resources are not part of the selected Customer Journey.', 'booking' ) );
70 }
71
72 $resource_drafts = isset( $validated_fields['booking_resources'] ) && is_array( $validated_fields['booking_resources'] ) ? $validated_fields['booking_resources'] : array();
73 $resource_updates = isset( $validated_fields['existing_booking_resources'] ) && is_array( $validated_fields['existing_booking_resources'] ) ? $validated_fields['existing_booking_resources'] : array();
74 if ( ! class_exists( 'wpdev_bk_personal' ) && ! empty( $resource_drafts ) ) {
75 return new WP_Error( 'wpbc_setup_wizard_resources_paid_required', __( 'Additional Booking Resources are available in Pro versions.', 'booking' ) );
76 }
77
78 if (
79 ! class_exists( 'WPBC_Catalog_Booking_Resource_Updater' )
80 || ! class_exists( 'WPBC_Catalog_Booking_Resources_Repository' )
81 || ( ! empty( $resource_drafts ) && ! class_exists( 'WPBC_Catalog_Booking_Resource_Creator' ) )
82 || ! function_exists( 'wpbc_catalog_booking_resources_get_manage_capability' )
83 ) {
84 return new WP_Error( 'wpbc_setup_wizard_resources_domain_unavailable', __( 'Booking Resources are temporarily unavailable. Restore a complete Booking Calendar package and try again.', 'booking' ) );
85 }
86 if ( ! current_user_can( wpbc_catalog_booking_resources_get_manage_capability() ) ) {
87 return new WP_Error( 'wpbc_setup_wizard_resources_forbidden', __( 'You are not allowed to manage Booking Resources.', 'booking' ) );
88 }
89 if ( empty( $resource_drafts ) && empty( $resource_updates ) ) {
90 return $this->create_result( array(), array(), array(), false );
91 }
92
93 $operation_id = isset( $operation_context['operation_id'] ) ? sanitize_key( (string) $operation_context['operation_id'] ) : '';
94 if ( '' === $operation_id ) {
95 return new WP_Error( 'wpbc_setup_wizard_resources_operation_invalid', __( 'The Booking Resource save operation is invalid. Reload the page and try again.', 'booking' ) );
96 }
97
98 $creator = ! empty( $resource_drafts ) ? new WPBC_Catalog_Booking_Resource_Creator() : null;
99 $updater = new WPBC_Catalog_Booking_Resource_Updater();
100 $repository = new WPBC_Catalog_Booking_Resources_Repository();
101 $journal_option_name = $this->get_journal_option_name();
102 $journal_before = get_option( $journal_option_name, false );
103 $journal = is_array( $journal_before ) ? $journal_before : array();
104 $retry_key = $this->get_retry_key( $resource_drafts, $resource_updates, $operation_context );
105 $operation_record = isset( $journal[ $retry_key ] ) && is_array( $journal[ $retry_key ] ) ? $journal[ $retry_key ] : array();
106 $resource_mappings = isset( $operation_record['resources'] ) && is_array( $operation_record['resources'] ) ? $operation_record['resources'] : array();
107 $update_mappings = isset( $operation_record['updates'] ) && is_array( $operation_record['updates'] ) ? $operation_record['updates'] : array();
108 $created_this_request = array();
109 $updated_before_images = array();
110 $saved_resources = array();
111 $updated_ids = array();
112
113 foreach ( $resource_updates as $resource_update ) {
114 $resource_id = absint( $resource_update['resource_id'] );
115 $current_resource = $this->get_current_owner_resource( $repository, $resource_id );
116 $target_fingerprint = $this->booking_resources->get_editable_resource_fields_fingerprint( $resource_update );
117 if ( is_wp_error( $current_resource ) ) {
118 return $this->compensate_error( $current_resource, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
119 }
120
121 $current_fingerprint = $this->booking_resources->get_existing_resource_fingerprint( $current_resource );
122 $journal_fingerprint = isset( $update_mappings[ $resource_id ] ) && is_scalar( $update_mappings[ $resource_id ] ) ? (string) $update_mappings[ $resource_id ] : '';
123 if ( $target_fingerprint === $journal_fingerprint && $target_fingerprint === $current_fingerprint ) {
124 $saved_resources[] = $this->get_saved_resource_fingerprint_value( $current_resource );
125 $updated_ids[] = $resource_id;
126 continue;
127 }
128 if ( ! hash_equals( (string) $resource_update['source_fingerprint'], $current_fingerprint ) ) {
129 $stale_error = new WP_Error( 'wpbc_setup_wizard_resource_update_stale', __( 'A Booking Resource changed after this step was opened. Reload the step before saving your changes.', 'booking' ) );
130
131 return $this->compensate_error( $stale_error, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
132 }
133
134 $updated_before_images[] = array(
135 'resource_id' => $resource_id,
136 'fields' => $this->booking_resources->get_existing_resource_editable_fields( $current_resource ),
137 );
138 $updated_resource = $updater->update( $resource_id, $this->get_updater_fields( $resource_update ) );
139 if ( is_wp_error( $updated_resource ) || ! is_array( $updated_resource ) ) {
140 $update_error = is_wp_error( $updated_resource ) ? $updated_resource : new WP_Error( 'wpbc_setup_wizard_resource_update_failed', __( 'The Booking Resource could not be updated.', 'booking' ) );
141
142 return $this->compensate_error( $update_error, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
143 }
144 $updated_resource_fingerprint = $this->booking_resources->get_existing_resource_fingerprint( $updated_resource );
145 if ( ! hash_equals( $target_fingerprint, $updated_resource_fingerprint ) ) {
146 $verification_error = new WP_Error( 'wpbc_setup_wizard_resource_update_unverified', __( 'The Booking Resource update could not be verified.', 'booking' ) );
147
148 return $this->compensate_error( $verification_error, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
149 }
150
151 $update_mappings[ $resource_id ] = $target_fingerprint;
152 $journal[ $retry_key ] = $this->build_operation_record( $operation_id, $resource_mappings, $update_mappings );
153 if ( ! $this->save_journal( $journal_option_name, $journal ) ) {
154 $journal_error = new WP_Error( 'wpbc_setup_wizard_resource_journal_failed', __( 'The Booking Resource retry state could not be saved. No changes were retained.', 'booking' ) );
155
156 return $this->compensate_error( $journal_error, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
157 }
158 $saved_resources[] = $this->get_saved_resource_fingerprint_value( $updated_resource );
159 $updated_ids[] = $resource_id;
160 }
161
162 foreach ( $resource_drafts as $resource_draft ) {
163 $draft_id = sanitize_key( (string) $resource_draft['draft_id'] );
164 $resource_id = isset( $resource_mappings[ $draft_id ] ) ? absint( $resource_mappings[ $draft_id ] ) : 0;
165 if ( $resource_id ) {
166 $saved_resource = $this->get_current_owner_resource( $repository, $resource_id );
167 if ( is_wp_error( $saved_resource ) ) {
168 return $this->compensate_error( $saved_resource, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
169 }
170 } else {
171 $storage_context = WPBC_Setup_Wizard_Access::get_storage_context();
172 $created = $creator->create(
173 array(
174 'title' => $resource_draft['title'],
175 'description' => $resource_draft['description'],
176 'picture_url' => $resource_draft['picture_url'],
177 'quantity' => 1,
178 'creation_mode' => 'independent',
179 'base_cost' => $resource_draft['base_cost'],
180 'owner_user_id' => absint( $storage_context['owner_user_id'] ),
181 )
182 );
183 if ( is_wp_error( $created ) || empty( $created['resource_ids'][0] ) ) {
184 $create_error = is_wp_error( $created ) ? $created : new WP_Error( 'wpbc_setup_wizard_resource_create_failed', __( 'The Booking Resource could not be created.', 'booking' ) );
185
186 return $this->compensate_error( $create_error, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
187 }
188 $resource_id = absint( $created['resource_ids'][0] );
189 $created_this_request[] = $resource_id;
190 $resource_mappings[ $draft_id ] = $resource_id;
191 $journal[ $retry_key ] = $this->build_operation_record( $operation_id, $resource_mappings, $update_mappings );
192 if ( ! $this->save_journal( $journal_option_name, $journal ) ) {
193 $journal_error = new WP_Error( 'wpbc_setup_wizard_resource_journal_failed', __( 'The Booking Resource retry state could not be saved. No changes were retained.', 'booking' ) );
194
195 return $this->compensate_error( $journal_error, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
196 }
197 $saved_resource = $this->get_current_owner_resource( $repository, $resource_id );
198 if ( is_wp_error( $saved_resource ) ) {
199 return $this->compensate_error( $saved_resource, $creator, $updater, $created_this_request, $updated_before_images, $journal_option_name, $journal_before );
200 }
201 }
202
203 $saved_resources[] = $this->get_saved_resource_fingerprint_value( $saved_resource );
204 }
205
206 $created_ids = array();
207 foreach ( $resource_mappings as $draft_id => $resource_id ) {
208 $created_ids[] = 'resource:' . sanitize_key( $draft_id ) . ':' . absint( $resource_id );
209 }
210
211 return $this->create_result( $created_ids, array_values( array_unique( array_map( 'absint', $updated_ids ) ) ), $saved_resources, true );
212 }
213
214 /**
215 * Load one Resource and verify effective-owner scope.
216 *
217 * @param WPBC_Catalog_Booking_Resources_Repository $repository Canonical repository.
218 * @param int $resource_id Resource ID.
219 *
220 * @return array<string,mixed>|WP_Error Authorized Resource or error.
221 */
222 private function get_current_owner_resource( $repository, $resource_id ) {
223 $resource = $repository->get_resource( $resource_id );
224 if ( is_wp_error( $resource ) || ! is_array( $resource ) || empty( $resource['id'] ) ) {
225 return new WP_Error( 'wpbc_setup_wizard_resource_unavailable', __( 'A Booking Resource is no longer available. Reload the step and try again.', 'booking' ) );
226 }
227 if ( class_exists( 'wpdev_bk_multiuser' ) ) {
228 $storage_context = WPBC_Setup_Wizard_Access::get_storage_context();
229 if ( absint( $storage_context['owner_user_id'] ) !== absint( isset( $resource['owner_user_id'] ) ? $resource['owner_user_id'] : 0 ) ) {
230 return new WP_Error( 'wpbc_setup_wizard_resource_owner_invalid', __( 'A Booking Resource does not belong to the current Booking Calendar owner.', 'booking' ) );
231 }
232 }
233
234 return $resource;
235 }
236
237 /**
238 * Return only fields accepted by the canonical updater.
239 *
240 * @param array<string,mixed> $resource_update Validated update.
241 *
242 * @return array<string,string> Canonical updater fields.
243 */
244 private function get_updater_fields( array $resource_update ) {
245 return array(
246 'title' => (string) $resource_update['title'],
247 'description' => (string) $resource_update['description'],
248 'picture_url' => (string) $resource_update['picture_url'],
249 'base_cost' => (string) $resource_update['base_cost'],
250 );
251 }
252
253 /**
254 * Build a minimal canonical result fingerprint value.
255 *
256 * @param array<string,mixed> $resource Canonical Resource.
257 *
258 * @return array<string,mixed> Non-sensitive fingerprint value.
259 */
260 private function get_saved_resource_fingerprint_value( array $resource ) {
261 return array(
262 'id' => absint( $resource['id'] ),
263 'fingerprint' => $this->booking_resources->get_existing_resource_fingerprint( $resource ),
264 );
265 }
266
267 /**
268 * Restore mutations and return either the original or compensation error.
269 *
270 * @param WP_Error $original_error Original safe error.
271 * @param WPBC_Catalog_Booking_Resource_Creator|null $creator Canonical creator when drafts exist.
272 * @param WPBC_Catalog_Booking_Resource_Updater $updater Canonical updater.
273 * @param int[] $created_resource_ids Resources created by this request.
274 * @param array<int,array<string,mixed>> $updated_before_images Existing values changed by this request.
275 * @param string $journal_option_name Owner-scoped option name.
276 * @param mixed $journal_before Original option value.
277 *
278 * @return WP_Error Safe error.
279 */
280 private function compensate_error( $original_error, $creator, $updater, array $created_resource_ids, array $updated_before_images, $journal_option_name, $journal_before ) {
281 $compensated = $this->compensate( $creator, $updater, $created_resource_ids, $updated_before_images, $journal_option_name, $journal_before );
282 if ( is_wp_error( $compensated ) ) {
283 return $compensated;
284 }
285
286 return $original_error;
287 }
288
289 /**
290 * Restore the journal, remove new Resources, and restore existing values.
291 *
292 * @param WPBC_Catalog_Booking_Resource_Creator|null $creator Canonical creator when drafts exist.
293 * @param WPBC_Catalog_Booking_Resource_Updater $updater Canonical updater.
294 * @param int[] $created_resource_ids Resources created by this request.
295 * @param array<int,array<string,mixed>> $updated_before_images Existing values changed by this request.
296 * @param string $journal_option_name Owner-scoped option name.
297 * @param mixed $journal_before Original option value.
298 *
299 * @return true|WP_Error True after compensation or a safe recovery error.
300 */
301 private function compensate( $creator, $updater, array $created_resource_ids, array $updated_before_images, $journal_option_name, $journal_before ) {
302 $compensation_failed = false;
303 if ( $creator instanceof WPBC_Catalog_Booking_Resource_Creator ) {
304 $created_resources_restored = $creator->compensate_created_resources( $created_resource_ids );
305 if ( is_wp_error( $created_resources_restored ) ) {
306 $compensation_failed = true;
307 }
308 }
309 foreach ( array_reverse( $updated_before_images ) as $before_image ) {
310 $restored = $updater->update( absint( $before_image['resource_id'] ), $before_image['fields'] );
311 if (
312 is_wp_error( $restored )
313 || ! is_array( $restored )
314 || ! hash_equals(
315 $this->booking_resources->get_editable_resource_fields_fingerprint( $before_image['fields'] ),
316 $this->booking_resources->get_existing_resource_fingerprint( $restored )
317 )
318 ) {
319 $compensation_failed = true;
320 }
321 }
322 if ( false === $journal_before ) {
323 delete_option( $journal_option_name );
324 $journal_restored = false === get_option( $journal_option_name, false );
325 } else {
326 update_option( $journal_option_name, is_array( $journal_before ) ? $journal_before : array(), false );
327 $journal_restored = get_option( $journal_option_name, false ) === ( is_array( $journal_before ) ? $journal_before : array() );
328 }
329 wp_cache_delete( $journal_option_name, 'options' );
330 if ( ! $journal_restored || $compensation_failed ) {
331 return new WP_Error( 'wpbc_setup_wizard_resource_compensation_failed', __( 'A Booking Resource change could not be safely restored. Reload the page before making further changes.', 'booking' ) );
332 }
333
334 return true;
335 }
336
337 /**
338 * Return a site- and effective-owner-scoped operation journal option.
339 *
340 * @return string Safe option name.
341 */
342 private function get_journal_option_name() {
343 $storage_context = WPBC_Setup_Wizard_Access::get_storage_context();
344
345 return 'wpbc_setup_resource_ops_' . absint( $storage_context['site_id'] ) . '_' . absint( $storage_context['owner_user_id'] );
346 }
347
348 /**
349 * Build a deterministic retry identity for one revision and complete payload.
350 *
351 * @param array<int,array<string,mixed>> $resource_drafts Validated new drafts.
352 * @param array<int,array<string,mixed>> $resource_updates Validated existing updates.
353 * @param array<string,mixed> $operation_context Progressive-save metadata.
354 *
355 * @return string Stable, non-customer-data journal key.
356 */
357 private function get_retry_key( array $resource_drafts, array $resource_updates, array $operation_context ) {
358 $expected_revision = isset( $operation_context['expected_revision'] ) ? (int) $operation_context['expected_revision'] : 0;
359 $payload_json = wp_json_encode(
360 array(
361 'drafts' => $resource_drafts,
362 'updates' => $resource_updates,
363 )
364 );
365 $payload_json = false === $payload_json ? '[]' : $payload_json;
366
367 return 'retry_' . hash( 'sha256', $expected_revision . '|' . $payload_json );
368 }
369
370 /**
371 * Build one bounded operation-journal record.
372 *
373 * @param string $operation_id Browser operation ID.
374 * @param array<string,int> $resource_mappings Draft-to-ID mappings.
375 * @param array<int|string,string> $update_mappings Resource-to-target mappings.
376 *
377 * @return array<string,mixed> Operation record.
378 */
379 private function build_operation_record( $operation_id, array $resource_mappings, array $update_mappings ) {
380 return array(
381 'created_at' => time(),
382 'operation_id' => sanitize_key( (string) $operation_id ),
383 'resources' => $resource_mappings,
384 'updates' => $update_mappings,
385 );
386 }
387
388 /**
389 * Persist a pruned retry journal and verify ambiguous update_option results.
390 *
391 * @param string $journal_option_name Option name.
392 * @param array<string,array<string,mixed>> $journal Operation records.
393 *
394 * @return bool True when the intended journal is stored.
395 */
396 private function save_journal( $journal_option_name, array $journal ) {
397 $journal = $this->prune_journal( $journal );
398
399 return update_option( $journal_option_name, $journal, false ) || $journal === get_option( $journal_option_name, array() );
400 }
401
402 /**
403 * Keep only the most recent bounded operation records.
404 *
405 * @param array<string,array<string,mixed>> $journal Operation records.
406 *
407 * @return array<string,array<string,mixed>> Bounded journal.
408 */
409 private function prune_journal( array $journal ) {
410 uasort(
411 $journal,
412 static function ( $first_record, $second_record ) {
413 $first_time = is_array( $first_record ) && isset( $first_record['created_at'] ) ? absint( $first_record['created_at'] ) : 0;
414 $second_time = is_array( $second_record ) && isset( $second_record['created_at'] ) ? absint( $second_record['created_at'] ) : 0;
415
416 return $first_time <=> $second_time;
417 }
418 );
419 while ( self::MAX_JOURNAL_OPERATIONS < count( $journal ) ) {
420 array_shift( $journal );
421 }
422
423 return $journal;
424 }
425
426 /**
427 * Build one normalized progressive-save result.
428 *
429 * @param string[] $created_ids Stable draft-to-Resource mappings.
430 * @param int[] $updated_ids Updated Resource IDs.
431 * @param array<int,array<string,mixed>> $saved_resources Canonical fingerprint values.
432 * @param bool $did_write Whether canonical values changed.
433 *
434 * @return array<string,mixed> Progressive result.
435 */
436 private function create_result( array $created_ids, array $updated_ids, array $saved_resources, $did_write ) {
437 $created_count = count( $created_ids );
438 $updated_count = count( $updated_ids );
439 if ( $created_count && $updated_count ) {
440 /* translators: 1: Number of created Resources. 2: Number of updated Resources. */
441 $summary = sprintf( __( '%1$d Booking Resources created and %2$d updated.', 'booking' ), $created_count, $updated_count );
442 } elseif ( $created_count ) {
443 $summary = sprintf( _n( '%d Booking Resource created.', '%d Booking Resources created.', $created_count, 'booking' ), $created_count );
444 } elseif ( $updated_count ) {
445 $summary = sprintf( _n( '%d Booking Resource updated.', '%d Booking Resources updated.', $updated_count, 'booking' ), $updated_count );
446 } else {
447 $summary = __( 'Existing Booking Resources retained.', 'booking' );
448 }
449
450 return array(
451 'status' => 'saved',
452 'summary' => $summary,
453 'canonical_fingerprint' => hash( 'sha256', wp_json_encode( $saved_resources ) ),
454 'created_ids' => $created_ids,
455 'updated_ids' => $updated_ids,
456 'warnings' => array(),
457 'writes_canonical_settings' => (bool) $did_write,
458 );
459 }
460
461 /** @return bool False because Review Setup completes the route. */
462 public function is_terminal() {
463 return false;
464 }
465 }
466