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-days-off / class-wpbc-setup-wizard-days-off.php

class-wpbc-setup-wizard-days-off.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-days-off/class-wpbc-setup-wizard-days-off.php

1,106 lines 43.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Domain service for the reusable Setup Wizard Days Off editor.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Read and mutate canonical per-date availability for authorized Resources.
14 *
15 * The editor deliberately uses the same `date_status=unavailable` records as
16 * Booking Calendar > Availability > Days Availability. It never introduces a
17 * second option or wizard-specific copy of canonical availability.
18 */
19 final class WPBC_Setup_Wizard_Days_Off {
20
21 const MAX_RANGE_DAYS = 366;
22 const MAX_STAGED_DATES = 10000;
23
24 /**
25 * Return the bounded field value staged by the Days Off editor.
26 *
27 * @return array{revision:string,unavailable_dates:array<int,string[]>} Staged canonical proposal.
28 */
29 public function get_initial_staged_values() {
30 $state = $this->get_state( 'all' );
31 if ( is_wp_error( $state ) ) {
32 return array( 'revision' => '', 'unavailable_dates' => array() );
33 }
34
35 return array(
36 'revision' => isset( $state['revision'] ) ? (string) $state['revision'] : '',
37 'unavailable_dates' => isset( $state['unavailable_dates'] ) && is_array( $state['unavailable_dates'] ) ? $state['unavailable_dates'] : array(),
38 );
39 }
40
41 /**
42 * Validate a complete future unavailability proposal for authorized Resources.
43 *
44 * @param mixed $raw_staged_values JSON transport string or stored array.
45 * @param bool $require_complete Whether the transport field is required.
46 *
47 * @return array{revision:string,unavailable_dates:array<int,string[]>}|WP_Error Normalized proposal or error.
48 */
49 public function validate_staged_values( $raw_staged_values, $require_complete ) {
50 if ( is_string( $raw_staged_values ) ) {
51 if ( strlen( $raw_staged_values ) > 500000 ) {
52 return new WP_Error( 'wpbc_setup_wizard_days_off_payload_too_large', __( 'The Days Off selection is too large.', 'booking' ) );
53 }
54 $raw_staged_values = json_decode( $raw_staged_values, true );
55 if ( JSON_ERROR_NONE !== json_last_error() ) {
56 return new WP_Error( 'wpbc_setup_wizard_days_off_payload_invalid', __( 'The Days Off selection is invalid.', 'booking' ) );
57 }
58 }
59
60 if ( ! is_array( $raw_staged_values ) || array_diff( array_keys( $raw_staged_values ), array( 'revision', 'unavailable_dates' ) ) ) {
61 return new WP_Error( 'wpbc_setup_wizard_days_off_payload_invalid', __( 'The Days Off selection is invalid.', 'booking' ) );
62 }
63 if ( $require_complete && ( ! array_key_exists( 'revision', $raw_staged_values ) || ! array_key_exists( 'unavailable_dates', $raw_staged_values ) ) ) {
64 return new WP_Error( 'wpbc_setup_wizard_days_off_payload_required', __( 'Reload Days Off and review the unavailable dates before continuing.', 'booking' ) );
65 }
66
67 $revision = isset( $raw_staged_values['revision'] ) && is_scalar( $raw_staged_values['revision'] )
68 ? sanitize_text_field( (string) $raw_staged_values['revision'] )
69 : '';
70 $raw_dates = isset( $raw_staged_values['unavailable_dates'] ) && is_array( $raw_staged_values['unavailable_dates'] )
71 ? $raw_staged_values['unavailable_dates']
72 : array();
73
74 if ( ! $this->current_user_can_manage() ) {
75 return array( 'revision' => '', 'unavailable_dates' => array() );
76 }
77
78 $resource_ids = array_map( 'absint', wp_list_pluck( $this->get_authorized_resources(), 'id' ) );
79 $submitted_ids = array_values( array_unique( array_map( 'absint', array_keys( $raw_dates ) ) ) );
80 sort( $resource_ids, SORT_NUMERIC );
81 sort( $submitted_ids, SORT_NUMERIC );
82 if ( $resource_ids !== $submitted_ids ) {
83 return new WP_Error( 'wpbc_setup_wizard_days_off_resources_changed', __( 'The available booking items changed. Reload Days Off and try again.', 'booking' ) );
84 }
85
86 $total_dates = 0;
87 $today = current_datetime()->format( 'Y-m-d' );
88 $dates = array();
89 foreach ( $resource_ids as $resource_id ) {
90 $resource_dates = isset( $raw_dates[ $resource_id ] ) && is_array( $raw_dates[ $resource_id ] ) ? $raw_dates[ $resource_id ] : array();
91 $dates[ $resource_id ] = array();
92 foreach ( $resource_dates as $raw_date ) {
93 $date = $this->validate_date( $raw_date );
94 if ( null === $date || $date < $today ) {
95 return new WP_Error( 'wpbc_setup_wizard_days_off_date_invalid', __( 'Days Off contains a date outside the supported future calendar.', 'booking' ) );
96 }
97 $dates[ $resource_id ][] = $date;
98 }
99 $dates[ $resource_id ] = array_values( array_unique( $dates[ $resource_id ] ) );
100 sort( $dates[ $resource_id ], SORT_STRING );
101 $total_dates += count( $dates[ $resource_id ] );
102 if ( self::MAX_STAGED_DATES < $total_dates ) {
103 return new WP_Error( 'wpbc_setup_wizard_days_off_dates_limit', __( 'The Days Off selection contains too many dates.', 'booking' ) );
104 }
105 }
106
107 return array(
108 'revision' => substr( $revision, 0, 128 ),
109 'unavailable_dates' => $dates,
110 );
111 }
112
113 /**
114 * Build presentation state using the staged proposal instead of live dates.
115 *
116 * @param mixed $raw_staged_values Validated or transported staged values.
117 * @param string $scope Resource scope to present.
118 *
119 * @return array<string,mixed>|WP_Error Staged editor state or error.
120 */
121 public function get_staged_state( $raw_staged_values, $scope = 'all' ) {
122 $state = $this->get_state( $scope );
123 if ( is_wp_error( $state ) || empty( $state['can_manage'] ) ) {
124 return $state;
125 }
126
127 $staged = $this->validate_staged_values( $raw_staged_values, true );
128 if ( is_wp_error( $staged ) ) {
129 return $state;
130 }
131
132 $resource_ids = array_map( 'absint', wp_list_pluck( $state['resources'], 'id' ) );
133 $target_ids = $this->resolve_scope_resource_ids( $scope, $resource_ids );
134 if ( is_wp_error( $target_ids ) ) {
135 return $target_ids;
136 }
137 $scoped_dates = array_intersect_key( $staged['unavailable_dates'], array_fill_keys( $target_ids, true ) );
138 $scoped_resources = array_values(
139 array_filter(
140 $state['resources'],
141 static function ( $resource ) use ( $target_ids ) {
142 return in_array( absint( $resource['id'] ), $target_ids, true );
143 }
144 )
145 );
146
147 $all_resource_ids = array_map( 'absint', array_keys( $staged['unavailable_dates'] ) );
148 $canonical_dates = $this->get_unavailable_dates( $all_resource_ids );
149 $state['unavailable_dates'] = $scoped_dates;
150 $state['date_states'] = $this->build_date_states( $target_ids, $scoped_dates );
151 $state['ranges'] = $this->build_range_records( $scoped_resources, $scoped_dates, 'all' === $scope ? 'all' : (string) $scope );
152 $state['revision'] = $this->get_staged_display_revision( $canonical_dates, $staged );
153
154 return $state;
155 }
156
157 /**
158 * Replace future canonical unavailable dates at the Save boundary.
159 *
160 * @param mixed $raw_staged_values Validated or transported staged values.
161 *
162 * @return array<string,mixed>|WP_Error Refreshed canonical state or error.
163 */
164 public function save_staged_values( $raw_staged_values ) {
165 if ( ! $this->current_user_can_manage() ) {
166 return $this->get_state( 'all' );
167 }
168
169 $staged = $this->validate_staged_values( $raw_staged_values, true );
170 if ( is_wp_error( $staged ) ) {
171 return $staged;
172 }
173
174 $resources = $this->get_authorized_resources();
175 $resource_ids = array_map( 'absint', wp_list_pluck( $resources, 'id' ) );
176 $current_dates = $this->get_unavailable_dates( $resource_ids );
177 if ( ! $this->can_apply_staged_values( $current_dates, $staged ) ) {
178 return new WP_Error( 'wpbc_setup_wizard_days_off_stale_state', __( 'Date availability changed in another request. Reload Days Off, review the changes, and try again.', 'booking' ) );
179 }
180 if ( $this->unavailable_dates_match( $current_dates, $staged['unavailable_dates'] ) ) {
181 return $this->get_state( 'all' );
182 }
183
184 $attempted = array();
185 foreach ( $resource_ids as $resource_id ) {
186 $remove_dates = array_values( array_diff( $current_dates[ $resource_id ], $staged['unavailable_dates'][ $resource_id ] ) );
187 $add_dates = array_values( array_diff( $staged['unavailable_dates'][ $resource_id ], $current_dates[ $resource_id ] ) );
188 foreach ( array( 'available' => $remove_dates, 'unavailable' => $add_dates ) as $status => $dates ) {
189 foreach ( $this->collapse_consecutive_dates( $dates ) as $range ) {
190 $attempted[ $resource_id ] = true;
191 if ( false === $this->write_date_range( $resource_id, $status, $range['first_date'], $range['last_date'] ) ) {
192 $restored = $this->restore_complete_dates( array_keys( $attempted ), $current_dates, $staged['unavailable_dates'] );
193 return new WP_Error(
194 'wpbc_setup_wizard_days_off_not_saved',
195 $restored
196 ? __( 'Days Off could not be saved. Previous date availability was restored.', 'booking' )
197 : __( 'Days Off could not be saved completely. Review Days Availability before continuing.', 'booking' )
198 );
199 }
200 }
201 }
202 }
203
204 $this->invalidate_availability_cache();
205 $verified = $this->get_unavailable_dates( $resource_ids );
206 if ( ! $this->unavailable_dates_match( $verified, $staged['unavailable_dates'] ) ) {
207 $restored = $this->restore_complete_dates( $resource_ids, $current_dates, $staged['unavailable_dates'] );
208 $message = $restored
209 ? __( 'Days Off could not be verified. Previous date availability was restored.', 'booking' )
210 : __( 'Days Off could not be verified or restored completely. Review Days Availability before continuing.', 'booking' );
211 return new WP_Error( 'wpbc_setup_wizard_days_off_not_verified', $message );
212 }
213
214 return $this->get_state( 'all' );
215 }
216
217 /**
218 * Decide whether a staged replacement is current or an idempotent replay.
219 *
220 * The second condition covers a safe retry after canonical date writes
221 * succeeded but the shared checkpoint commit did not. A stale revision is
222 * still rejected whenever the submitted dates differ from current storage.
223 *
224 * @param array<int,string[]> $current_dates Current canonical unavailable dates.
225 * @param array<string,mixed> $staged Validated staged proposal.
226 *
227 * @return bool True when the proposal may be applied or treated as applied.
228 */
229 private function can_apply_staged_values( array $current_dates, array $staged ) {
230 return hash_equals( $this->get_state_revision( $current_dates ), (string) $staged['revision'] )
231 || $this->unavailable_dates_match( $current_dates, $staged['unavailable_dates'] );
232 }
233
234 /**
235 * Refresh a staged revision only when its complete dates equal canonical state.
236 *
237 * @param array<int,string[]> $canonical_dates Current canonical unavailable dates.
238 * @param array<string,mixed> $staged Validated staged proposal.
239 *
240 * @return string Current revision for an applied proposal, otherwise its base revision.
241 */
242 private function get_staged_display_revision( array $canonical_dates, array $staged ) {
243 return $this->unavailable_dates_match( $canonical_dates, $staged['unavailable_dates'] )
244 ? $this->get_state_revision( $canonical_dates )
245 : (string) $staged['revision'];
246 }
247
248 /**
249 * Write one inclusive date range through the canonical Availability API.
250 *
251 * @param int $resource_id Resource ID.
252 * @param string $status `available` or `unavailable`.
253 * @param string $first_date First ISO date.
254 * @param string $last_date Last ISO date.
255 *
256 * @return bool True when the canonical writer did not fail.
257 */
258 private function write_date_range( $resource_id, $status, $first_date, $last_date ) {
259 return false !== wpbc_availability__update_dates_status__sql(
260 array(
261 'resource_id' => absint( $resource_id ),
262 'prop_name' => 'date_status',
263 'prop_value' => $status,
264 'dates_selection' => $first_date . ' ~ ' . $last_date,
265 )
266 );
267 }
268
269 /**
270 * Restore complete future before-images after a failed staged replacement.
271 *
272 * @param int[] $resource_ids Resource IDs to restore.
273 * @param array<int,string[]> $before_dates Original unavailable dates.
274 * @param array<int,string[]> $desired_dates Attempted unavailable dates.
275 *
276 * @return bool True when every compensation write succeeded.
277 */
278 private function restore_complete_dates( array $resource_ids, array $before_dates, array $desired_dates ) {
279 $restored = true;
280 foreach ( $resource_ids as $resource_id ) {
281 $union = array_values( array_unique( array_merge( isset( $before_dates[ $resource_id ] ) ? $before_dates[ $resource_id ] : array(), isset( $desired_dates[ $resource_id ] ) ? $desired_dates[ $resource_id ] : array() ) ) );
282 foreach ( $this->collapse_consecutive_dates( $union ) as $range ) {
283 $restored = $this->write_date_range( $resource_id, 'available', $range['first_date'], $range['last_date'] ) && $restored;
284 }
285 foreach ( $this->collapse_consecutive_dates( isset( $before_dates[ $resource_id ] ) ? $before_dates[ $resource_id ] : array() ) as $range ) {
286 $restored = $this->write_date_range( $resource_id, 'unavailable', $range['first_date'], $range['last_date'] ) && $restored;
287 }
288 }
289 $this->invalidate_availability_cache();
290 if ( ! $restored ) {
291 return false;
292 }
293
294 $verified_dates = $this->get_unavailable_dates( $resource_ids );
295 foreach ( $resource_ids as $resource_id ) {
296 $expected_dates = isset( $before_dates[ $resource_id ] ) ? $before_dates[ $resource_id ] : array();
297 $actual_dates = isset( $verified_dates[ $resource_id ] ) ? $verified_dates[ $resource_id ] : array();
298 if ( $expected_dates !== $actual_dates ) {
299 return false;
300 }
301 }
302
303 return true;
304 }
305
306 /**
307 * Return whether the current user may manage canonical date availability.
308 *
309 * Live-demo users who can open the Setup Wizard are deliberately allowed to
310 * exercise this step. Some long-lived demo fixtures retain an older, stricter
311 * Availability role option even though their temporary user is authorized for
312 * the wizard. Normal installations continue to use the configured Availability
313 * role without any relaxation.
314 *
315 * @return bool True when the configured Availability role is satisfied.
316 */
317 public function current_user_can_manage() {
318 if (
319 WPBC_Setup_Wizard_Environment_Policy::is_live_demo()
320 && WPBC_Setup_Wizard_Access::current_user_can_access()
321 ) {
322 return true;
323 }
324
325 $availability_role = sanitize_key( (string) get_bk_option( 'booking_user_role_availability' ) );
326
327 if ( '' === $availability_role ) {
328 return false;
329 }
330 if ( function_exists( 'wpbc_is_current_user_have_this_role' ) ) {
331 return (bool) wpbc_is_current_user_have_this_role( $availability_role );
332 }
333
334 $capabilities = array(
335 'administrator' => 'activate_plugins',
336 'editor' => 'publish_pages',
337 'author' => 'publish_posts',
338 'contributor' => 'edit_posts',
339 'subscriber' => 'read',
340 );
341
342 return isset( $capabilities[ $availability_role ] ) && current_user_can( $capabilities[ $availability_role ] );
343 }
344
345 /**
346 * Build the data-only editor state from canonical Availability records.
347 *
348 * @param string $scope `all` or one authorized Resource ID.
349 *
350 * @return array<string,mixed>|WP_Error Authorized scoped state or a safe error.
351 */
352 public function get_state( $scope = 'all' ) {
353 $can_manage = $this->current_user_can_manage();
354 if ( ! $can_manage ) {
355 $today = current_datetime()->format( 'Y-m-d' );
356
357 return array(
358 'can_manage' => false,
359 'has_resources' => false,
360 'resources' => array(),
361 'apply_options' => array(),
362 'unavailable_dates' => array(),
363 'date_states' => array(),
364 'booking_date_states' => array(),
365 'ranges' => array(),
366 'revision' => '',
367 'scope' => '',
368 'is_aggregate_scope' => false,
369 'today' => $today,
370 'default_first_date' => $today,
371 'default_last_date' => $today,
372 'first_day' => max( 0, min( 6, (int) get_bk_option( 'booking_start_day_weeek' ) ) ),
373 'max_range_days' => self::MAX_RANGE_DAYS,
374 );
375 }
376
377 $resources = $this->get_authorized_resources();
378 $resource_ids = array_map( 'absint', wp_list_pluck( $resources, 'id' ) );
379 $all_unavailable_dates = $this->get_unavailable_dates( $resource_ids );
380 $today = current_datetime()->format( 'Y-m-d' );
381 $apply_options = array();
382
383 if ( ! empty( $resources ) ) {
384 $apply_options[] = array(
385 'value' => 'all',
386 'label' => __( 'All booking items', 'booking' ),
387 );
388 }
389 if ( count( $resources ) > 1 ) {
390 foreach ( $resources as $resource ) {
391 $apply_options[] = array(
392 'value' => (string) $resource['id'],
393 'label' => $resource['label'],
394 );
395 }
396 }
397
398 $normalized_scope = is_scalar( $scope ) ? sanitize_text_field( (string) $scope ) : '';
399 $target_resource_ids = $this->resolve_scope_resource_ids( $normalized_scope, $resource_ids );
400 if ( is_wp_error( $target_resource_ids ) ) {
401 return $target_resource_ids;
402 }
403
404 $scoped_resources = array();
405 $scoped_unavailable_dates = array();
406 foreach ( $resources as $resource ) {
407 $resource_id = absint( $resource['id'] );
408 if ( ! in_array( $resource_id, $target_resource_ids, true ) ) {
409 continue;
410 }
411 $scoped_resources[] = $resource;
412 $scoped_unavailable_dates[ $resource_id ] = isset( $all_unavailable_dates[ $resource_id ] ) ? $all_unavailable_dates[ $resource_id ] : array();
413 }
414
415 $single_resource_scope = 'all' === $normalized_scope ? 'all' : $normalized_scope;
416 $ranges = $this->build_range_records( $scoped_resources, $scoped_unavailable_dates, $single_resource_scope );
417 $is_aggregate_scope = 'all' === $normalized_scope && 1 < count( $target_resource_ids );
418
419 return array(
420 'can_manage' => $can_manage,
421 'has_resources' => ! empty( $resources ),
422 'resources' => $resources,
423 'apply_options' => $apply_options,
424 'unavailable_dates' => $scoped_unavailable_dates,
425 'date_states' => $this->build_date_states( $target_resource_ids, $scoped_unavailable_dates ),
426 'booking_date_states' => $this->get_booking_date_states( $target_resource_ids, $is_aggregate_scope ),
427 'ranges' => $ranges,
428 'revision' => $this->get_state_revision( $all_unavailable_dates ),
429 'scope' => $normalized_scope,
430 'is_aggregate_scope' => $is_aggregate_scope,
431 'today' => $today,
432 'default_first_date' => $today,
433 'default_last_date' => $today,
434 'first_day' => max( 0, min( 6, (int) get_bk_option( 'booking_start_day_weeek' ) ) ),
435 'max_range_days' => self::MAX_RANGE_DAYS,
436 );
437 }
438
439 /**
440 * Add or remove one validated unavailable date range.
441 *
442 * A state revision prevents a stale browser from deleting dates changed by
443 * another administrator after the list was rendered. Multi-resource writes
444 * use bounded compensation because WordPress installations cannot be assumed
445 * to use a transaction-capable table engine.
446 *
447 * @param string $operation Either `add` or `remove`.
448 * @param string $scope `all` or one authorized Resource ID.
449 * @param string $first_date First date in `Y-m-d` format.
450 * @param string $last_date Last date in `Y-m-d` format.
451 * @param string $expected_revision Client state revision.
452 * @param string $view_scope Scope that must remain visible after the write.
453 *
454 * @return array<string,mixed>|WP_Error Refreshed state or a safe error.
455 */
456 public function mutate_range( $operation, $scope, $first_date, $last_date, $expected_revision, $view_scope = 'all' ) {
457 if ( ! $this->current_user_can_manage() ) {
458 return new WP_Error( 'wpbc_setup_wizard_days_off_forbidden', __( 'You are not allowed to change date availability.', 'booking' ) );
459 }
460
461 $operation = sanitize_key( (string) $operation );
462 if ( ! in_array( $operation, array( 'add', 'remove' ), true ) ) {
463 return new WP_Error( 'wpbc_setup_wizard_days_off_operation_invalid', __( 'Choose a valid date availability action.', 'booking' ) );
464 }
465
466 $validated_range = $this->validate_range( $first_date, $last_date );
467 if ( is_wp_error( $validated_range ) ) {
468 return $validated_range;
469 }
470
471 $resources = $this->get_authorized_resources();
472 $resource_ids = array_map( 'absint', wp_list_pluck( $resources, 'id' ) );
473 $current_dates = $this->get_unavailable_dates( $resource_ids );
474 $current_revision = $this->get_state_revision( $current_dates );
475 $view_scope = is_scalar( $view_scope ) ? sanitize_text_field( (string) $view_scope ) : '';
476 $view_resource_ids = $this->resolve_scope_resource_ids( $view_scope, $resource_ids );
477 if ( is_wp_error( $view_resource_ids ) ) {
478 return $view_resource_ids;
479 }
480
481 if ( ! hash_equals( $current_revision, sanitize_text_field( (string) $expected_revision ) ) ) {
482 return new WP_Error(
483 'wpbc_setup_wizard_days_off_stale_state',
484 __( 'Date availability changed in another request. Review the refreshed list and try again.', 'booking' ),
485 array( 'state' => $this->get_state( $view_scope ) )
486 );
487 }
488
489 $target_resource_ids = $this->resolve_scope_resource_ids( $scope, $resource_ids );
490 if ( is_wp_error( $target_resource_ids ) ) {
491 return $target_resource_ids;
492 }
493 if ( empty( $target_resource_ids ) ) {
494 return new WP_Error( 'wpbc_setup_wizard_days_off_resources_empty', __( 'No booking items are available for this date range.', 'booking' ) );
495 }
496
497 $dates_selection = $validated_range['first_date'] . ' ~ ' . $validated_range['last_date'];
498 $attempted_resources = array();
499 $before_images = array();
500 foreach ( $target_resource_ids as $resource_id ) {
501 $before_images[ $resource_id ] = $this->filter_dates_to_range(
502 isset( $current_dates[ $resource_id ] ) ? $current_dates[ $resource_id ] : array(),
503 $validated_range['first_date'],
504 $validated_range['last_date']
505 );
506 $attempted_resources[] = $resource_id;
507 $mutation_result = wpbc_availability__update_dates_status__sql(
508 array(
509 'resource_id' => $resource_id,
510 'prop_name' => 'date_status',
511 'prop_value' => 'add' === $operation ? 'unavailable' : 'available',
512 'dates_selection' => $dates_selection,
513 )
514 );
515
516 if ( false === $mutation_result ) {
517 $compensation_succeeded = $this->restore_before_images( $attempted_resources, $before_images, $dates_selection );
518
519 return new WP_Error(
520 'wpbc_setup_wizard_days_off_not_saved',
521 $compensation_succeeded
522 ? __( 'The unavailable dates could not be saved. The previous date availability was restored.', 'booking' )
523 : __( 'The unavailable dates could not be saved completely. Reload the page and review Days Availability before trying again.', 'booking' )
524 );
525 }
526 }
527
528 $this->invalidate_availability_cache();
529
530 return $this->get_state( $view_scope );
531 }
532
533 /**
534 * Return current-user Resources without trusting a request owner identifier.
535 *
536 * @return array<int,array{id:int,label:string}> Authorized Resource records.
537 */
538 private function get_authorized_resources() {
539 if ( ! class_exists( 'wpdev_bk_personal' ) ) {
540 return array(
541 array(
542 'id' => 1,
543 'label' => __( 'Standard', 'booking' ),
544 ),
545 );
546 }
547
548 global $wpdb;
549 $table_name = $wpdb->prefix . 'bookingtypes';
550 $sql = "SELECT booking_type_id, title FROM {$table_name} WHERE 1=1";
551 $query_parameters = array();
552
553 if ( class_exists( 'wpdev_bk_multiuser' ) ) {
554 $current_user_id = function_exists( 'wpbc_get_current_user_id' ) ? absint( wpbc_get_current_user_id() ) : get_current_user_id();
555 $is_super_admin = $current_user_id && (bool) apply_bk_filter( 'is_user_super_admin', $current_user_id );
556 if ( ! $is_super_admin ) {
557 $sql .= ' AND users = %d';
558 $query_parameters[] = $current_user_id;
559 }
560 }
561
562 $sql .= $this->get_resource_order_by_sql();
563 if ( ! empty( $query_parameters ) ) {
564 $sql = $wpdb->prepare( $sql, $query_parameters ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
565 }
566
567 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
568 $database_rows = $wpdb->get_results( $sql, ARRAY_A );
569 $resources = array();
570 foreach ( (array) $database_rows as $database_row ) {
571 $resource_id = isset( $database_row['booking_type_id'] ) ? absint( $database_row['booking_type_id'] ) : 0;
572 if ( ! $resource_id ) {
573 continue;
574 }
575 $raw_title = isset( $database_row['title'] ) ? (string) $database_row['title'] : '';
576 $resources[] = array(
577 'id' => $resource_id,
578 'label' => sanitize_text_field( function_exists( 'wpbc_lang' ) ? wpbc_lang( $raw_title ) : $raw_title ),
579 );
580 }
581
582 return $resources;
583 }
584
585 /**
586 * Return an edition-safe Resource ordering clause.
587 *
588 * The Personal, Business Small, and Business Medium Resource tables do not
589 * contain the Business Large hierarchy columns. Querying those columns makes
590 * WordPress return no rows and incorrectly hides the Days Off editor. Keep the
591 * ordering aligned with the canonical Booking Listing Resource query instead.
592 *
593 * @return string Safe SQL ORDER BY clause for the active edition.
594 */
595 private function get_resource_order_by_sql() {
596 if ( class_exists( 'wpdev_bk_biz_l' ) ) {
597 return ' ORDER BY parent ASC, prioritet ASC, title ASC, booking_type_id ASC';
598 }
599
600 return ' ORDER BY title ASC, booking_type_id ASC';
601 }
602
603 /**
604 * Read future unavailable dates for authorized Resources.
605 *
606 * @param int[] $resource_ids Authorized Resource IDs.
607 *
608 * @return array<int,string[]> Dates keyed by Resource ID.
609 */
610 private function get_unavailable_dates( array $resource_ids ) {
611 $normalized = array();
612 foreach ( $resource_ids as $resource_id ) {
613 $resource_id = absint( $resource_id );
614 $normalized[ $resource_id ] = array();
615 }
616 if ( empty( $resource_ids ) ) {
617 return $normalized;
618 }
619
620 $dates_by_resource = wpbc_for_resources_arr__get_unavailable_dates( $resource_ids, 'CURDATE' );
621 foreach ( $normalized as $resource_id => $unused_dates ) {
622 $dates = isset( $dates_by_resource[ $resource_id ] ) && is_array( $dates_by_resource[ $resource_id ] )
623 ? array_values( array_unique( array_filter( array_map( 'sanitize_text_field', $dates_by_resource[ $resource_id ] ) ) ) )
624 : array();
625 sort( $dates, SORT_STRING );
626 $normalized[ $resource_id ] = $dates;
627 }
628
629 return $this->normalize_unavailable_dates( $normalized );
630 }
631
632 /**
633 * Compare complete unavailable-date maps without transport-order sensitivity.
634 *
635 * JSON objects with numeric Resource IDs are enumerated in numeric order by
636 * browsers, while the canonical Resource query uses hierarchy and priority
637 * order. Both representations describe the same state, so stale-write and
638 * verification checks must compare their canonical forms rather than PHP
639 * insertion order.
640 *
641 * @param array<int,string[]> $first_dates First unavailable-date map.
642 * @param array<int,string[]> $second_dates Second unavailable-date map.
643 *
644 * @return bool True when both maps contain the same Resource dates.
645 */
646 private function unavailable_dates_match( array $first_dates, array $second_dates ) {
647 return $this->normalize_unavailable_dates( $first_dates ) === $this->normalize_unavailable_dates( $second_dates );
648 }
649
650 /**
651 * Build one deterministic representation of unavailable dates.
652 *
653 * Resource IDs are ordered numerically and each Resource date list is
654 * de-duplicated and sorted. This representation is used only after Resource
655 * authorization and date validation have established the allowed content.
656 *
657 * @param array<int,string[]> $dates_by_resource Unavailable dates keyed by Resource ID.
658 *
659 * @return array<int,string[]> Canonically ordered unavailable dates.
660 */
661 private function normalize_unavailable_dates( array $dates_by_resource ) {
662 $normalized_dates = array();
663
664 foreach ( $dates_by_resource as $resource_id => $resource_dates ) {
665 $resource_id = absint( $resource_id );
666 if ( ! $resource_id ) {
667 continue;
668 }
669
670 $resource_dates = is_array( $resource_dates ) ? $resource_dates : array();
671 $resource_dates = array_values( array_unique( array_map( 'sanitize_text_field', $resource_dates ) ) );
672 sort( $resource_dates, SORT_STRING );
673 $normalized_dates[ $resource_id ] = $resource_dates;
674 }
675
676 ksort( $normalized_dates, SORT_NUMERIC );
677
678 return $normalized_dates;
679 }
680
681 /**
682 * Build the calendar state for one authorized Resource scope.
683 *
684 * A date is unavailable only when every Resource in the scope is closed.
685 * When only some Resources are closed, the date remains selectable and is
686 * exposed as partially available so the aggregate `all` view is truthful.
687 *
688 * @param int[] $resource_ids Resource IDs in the current scope.
689 * @param array<int,string[]> $dates_by_resource Unavailable dates keyed by Resource ID.
690 *
691 * @return array<string,string> State keyed by canonical `Y-m-d` date.
692 */
693 private function build_date_states( array $resource_ids, array $dates_by_resource ) {
694 $resource_count = count( $resource_ids );
695 $date_counts = array();
696
697 if ( 0 === $resource_count ) {
698 return array();
699 }
700
701 foreach ( $resource_ids as $resource_id ) {
702 $resource_id = absint( $resource_id );
703 $dates = isset( $dates_by_resource[ $resource_id ] ) ? $dates_by_resource[ $resource_id ] : array();
704 foreach ( $dates as $date ) {
705 $date_counts[ $date ] = isset( $date_counts[ $date ] ) ? $date_counts[ $date ] + 1 : 1;
706 }
707 }
708
709 ksort( $date_counts, SORT_STRING );
710 $date_states = array();
711 foreach ( $date_counts as $date => $unavailable_resource_count ) {
712 $date_states[ $date ] = $resource_count === $unavailable_resource_count ? 'unavailable' : 'partially_available';
713 }
714
715 return $date_states;
716 }
717
718 /**
719 * Read future booking dates through Booking Calendar's canonical producer.
720 *
721 * A single Resource receives the same full-day and time-slot distinctions as
722 * the native Days Availability calendar. The all-Resources view deliberately
723 * exposes only a neutral aggregate marker because one booking must not imply
724 * that every authorized Resource is booked.
725 *
726 * @param int[] $resource_ids Authorized Resource IDs in the current scope.
727 * @param bool $is_aggregate_scope Whether more than one Resource is represented.
728 *
729 * @return array<string,string> Presentation state keyed by canonical `Y-m-d` date.
730 */
731 private function get_booking_date_states( array $resource_ids, $is_aggregate_scope ) {
732 if ( empty( $resource_ids ) || ! function_exists( 'wpbc__sql__get_booked_dates' ) ) {
733 return array();
734 }
735
736 $resource_ids = array_values( array_filter( array_unique( array_map( 'absint', $resource_ids ) ) ) );
737 if ( empty( $resource_ids ) ) {
738 return array();
739 }
740
741 $booked_dates = wpbc__sql__get_booked_dates(
742 array(
743 'resource_id' => implode( ',', $resource_ids ),
744 )
745 );
746
747 return $this->build_booking_date_states( is_array( $booked_dates ) ? $booked_dates : array(), (bool) $is_aggregate_scope );
748 }
749
750 /**
751 * Map canonical booked-date records to executable-free calendar state names.
752 *
753 * The mapping mirrors `wpbc__inline_booking_calendar__apply_css_to_days()`:
754 * `sec_0` is a full-day booking, while non-zero seconds are time-slot or
755 * changeover bookings. Any pending timed record makes that date pending.
756 *
757 * @param array<string,array<string,object|array<string,mixed>>> $booked_dates Canonical booked dates.
758 * @param bool $is_aggregate_scope Whether multiple Resources are represented.
759 *
760 * @return array<string,string> Presentation state keyed by canonical `Y-m-d` date.
761 */
762 private function build_booking_date_states( array $booked_dates, $is_aggregate_scope ) {
763 $date_states = array();
764
765 foreach ( $booked_dates as $calendar_date => $bookings_in_date ) {
766 $date_parts = array_map( 'absint', explode( '-', (string) $calendar_date ) );
767 if ( 3 !== count( $date_parts ) || ! checkdate( $date_parts[0], $date_parts[1], $date_parts[2] ) || ! is_array( $bookings_in_date ) || empty( $bookings_in_date ) ) {
768 continue;
769 }
770
771 $iso_date = sprintf( '%04d-%02d-%02d', $date_parts[2], $date_parts[0], $date_parts[1] );
772 if ( $is_aggregate_scope ) {
773 $date_states[ $iso_date ] = 'resources_have_bookings';
774 continue;
775 }
776
777 if ( isset( $bookings_in_date['sec_0'] ) ) {
778 $full_day_booking = $bookings_in_date['sec_0'];
779 $full_day_is_approved = is_object( $full_day_booking )
780 ? ! empty( $full_day_booking->approved )
781 : ( is_array( $full_day_booking ) && ! empty( $full_day_booking['approved'] ) );
782 $date_states[ $iso_date ] = $full_day_is_approved ? 'approved_full' : 'pending_full';
783 continue;
784 }
785
786 $all_bookings_approved = true;
787 foreach ( $bookings_in_date as $booking_record ) {
788 $is_approved = is_object( $booking_record )
789 ? ! empty( $booking_record->approved )
790 : ( is_array( $booking_record ) && ! empty( $booking_record['approved'] ) );
791 if ( ! $is_approved ) {
792 $all_bookings_approved = false;
793 break;
794 }
795 }
796 $date_states[ $iso_date ] = $all_bookings_approved ? 'approved_partial' : 'pending_partial';
797 }
798
799 ksort( $date_states, SORT_STRING );
800
801 return $date_states;
802 }
803
804 /**
805 * Convert consecutive canonical dates into compact upcoming range records.
806 *
807 * Exact ranges shared by every authorized Resource are collapsed into one
808 * `all` record. Other ranges retain their Resource scope.
809 *
810 * @param array<int,array{id:int,label:string}> $resources Authorized Resources.
811 * @param array<int,string[]> $dates_by_resource Unavailable dates by Resource.
812 * @param string $single_scope Scope used when one Resource is shown.
813 *
814 * @return array<int,array<string,mixed>> Range presentation records.
815 */
816 private function build_range_records( array $resources, array $dates_by_resource, $single_scope = 'all' ) {
817 $resource_labels = array();
818 $resource_ranges = array();
819 $range_counts = array();
820
821 foreach ( $resources as $resource ) {
822 $resource_id = absint( $resource['id'] );
823 $resource_labels[ $resource_id ] = (string) $resource['label'];
824 $resource_ranges[ $resource_id ] = $this->collapse_consecutive_dates(
825 isset( $dates_by_resource[ $resource_id ] ) ? $dates_by_resource[ $resource_id ] : array()
826 );
827 foreach ( $resource_ranges[ $resource_id ] as $range ) {
828 $range_key = $range['first_date'] . '|' . $range['last_date'];
829 $range_counts[ $range_key ] = isset( $range_counts[ $range_key ] ) ? $range_counts[ $range_key ] + 1 : 1;
830 }
831 }
832
833 $records = array();
834 $resource_count = count( $resources );
835 $shared_added = array();
836 foreach ( $resource_ranges as $resource_id => $ranges ) {
837 foreach ( $ranges as $range ) {
838 $range_key = $range['first_date'] . '|' . $range['last_date'];
839 $is_shared = 1 < $resource_count && isset( $range_counts[ $range_key ] ) && $resource_count === $range_counts[ $range_key ];
840 if ( $is_shared && isset( $shared_added[ $range_key ] ) ) {
841 continue;
842 }
843 $scope = $is_shared ? 'all' : ( 1 === $resource_count ? $single_scope : (string) $resource_id );
844 $scope_label = 'all' === $scope ? __( 'All booking items', 'booking' ) : $resource_labels[ $resource_id ];
845 $records[] = $this->format_range_record( $range['first_date'], $range['last_date'], $scope, $scope_label );
846 if ( $is_shared ) {
847 $shared_added[ $range_key ] = true;
848 }
849 }
850 }
851
852 usort(
853 $records,
854 static function ( $first_record, $second_record ) {
855 return strcmp( $first_record['first_date'] . '|' . $first_record['scope'], $second_record['first_date'] . '|' . $second_record['scope'] );
856 }
857 );
858
859 return $records;
860 }
861
862 /**
863 * Collapse ordered date strings into consecutive inclusive ranges.
864 *
865 * @param string[] $dates Ordered or unordered `Y-m-d` dates.
866 *
867 * @return array<int,array{first_date:string,last_date:string}> Date ranges.
868 */
869 private function collapse_consecutive_dates( array $dates ) {
870 $dates = array_values( array_unique( $dates ) );
871 sort( $dates, SORT_STRING );
872 $ranges = array();
873 $current_first = '';
874 $current_last = '';
875
876 foreach ( $dates as $date ) {
877 if ( '' === $current_first ) {
878 $current_first = $date;
879 $current_last = $date;
880 continue;
881 }
882
883 $expected_next = ( new DateTimeImmutable( $current_last, new DateTimeZone( 'UTC' ) ) )->modify( '+1 day' )->format( 'Y-m-d' );
884 if ( $date === $expected_next ) {
885 $current_last = $date;
886 continue;
887 }
888
889 $ranges[] = array( 'first_date' => $current_first, 'last_date' => $current_last );
890 $current_first = $date;
891 $current_last = $date;
892 }
893 if ( '' !== $current_first ) {
894 $ranges[] = array( 'first_date' => $current_first, 'last_date' => $current_last );
895 }
896
897 return $ranges;
898 }
899
900 /**
901 * Format one range without exposing executable or storage data.
902 *
903 * @param string $first_date First date.
904 * @param string $last_date Last date.
905 * @param string $scope Valid Resource scope.
906 * @param string $scope_label Scope label.
907 *
908 * @return array<string,mixed> Presentation record.
909 */
910 private function format_range_record( $first_date, $last_date, $scope, $scope_label ) {
911 $first_datetime = new DateTimeImmutable( $first_date, wp_timezone() );
912 $last_datetime = new DateTimeImmutable( $last_date, wp_timezone() );
913 $day_count = (int) $first_datetime->diff( $last_datetime )->days + 1;
914 $date_format = (string) get_bk_option( 'booking_date_format' );
915 if ( '' === $date_format ) {
916 $date_format = (string) get_option( 'date_format', 'M j, Y' );
917 }
918 $first_label = wp_date( $date_format, $first_datetime->getTimestamp(), wp_timezone() );
919 $last_label = wp_date( $date_format, $last_datetime->getTimestamp(), wp_timezone() );
920
921 return array(
922 'id' => hash( 'sha256', $scope . '|' . $first_date . '|' . $last_date ),
923 'first_date' => $first_date,
924 'last_date' => $last_date,
925 'label' => $first_date === $last_date ? $first_label : $first_label . " \u{2013} " . $last_label,
926 'day_count' => $day_count,
927 'scope' => $scope,
928 'scope_label' => sanitize_text_field( $scope_label ),
929 );
930 }
931
932 /**
933 * Validate a bounded future date range.
934 *
935 * @param mixed $raw_first_date Untrusted first date.
936 * @param mixed $raw_last_date Untrusted last date.
937 *
938 * @return array{first_date:string,last_date:string}|WP_Error Validated range.
939 */
940 private function validate_range( $raw_first_date, $raw_last_date ) {
941 $first_date = $this->validate_date( $raw_first_date );
942 $last_date = $this->validate_date( $raw_last_date );
943 if ( null === $first_date || null === $last_date ) {
944 return new WP_Error( 'wpbc_setup_wizard_days_off_date_invalid', __( 'Select a valid first and last date.', 'booking' ) );
945 }
946
947 $first_datetime = new DateTimeImmutable( $first_date, new DateTimeZone( 'UTC' ) );
948 $last_datetime = new DateTimeImmutable( $last_date, new DateTimeZone( 'UTC' ) );
949 $today = new DateTimeImmutable( current_datetime()->format( 'Y-m-d' ), new DateTimeZone( 'UTC' ) );
950 if ( $first_datetime < $today || $last_datetime < $first_datetime ) {
951 return new WP_Error( 'wpbc_setup_wizard_days_off_range_invalid', __( 'Choose a date range that starts today or later and ends after it starts.', 'booking' ) );
952 }
953
954 $day_count = (int) $first_datetime->diff( $last_datetime )->days + 1;
955 if ( self::MAX_RANGE_DAYS < $day_count ) {
956 return new WP_Error(
957 'wpbc_setup_wizard_days_off_range_too_large',
958 /* translators: %d: Maximum number of dates in one range. */
959 sprintf( __( 'Choose no more than %d days in one unavailable range.', 'booking' ), self::MAX_RANGE_DAYS )
960 );
961 }
962
963 return array( 'first_date' => $first_date, 'last_date' => $last_date );
964 }
965
966 /**
967 * Validate one exact Gregorian `Y-m-d` date.
968 *
969 * @param mixed $raw_date Untrusted date.
970 *
971 * @return string|null Valid date or null.
972 */
973 private function validate_date( $raw_date ) {
974 if ( ! is_scalar( $raw_date ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $raw_date ) ) {
975 return null;
976 }
977
978 $date = DateTimeImmutable::createFromFormat( '!Y-m-d', (string) $raw_date, new DateTimeZone( 'UTC' ) );
979 $errors = DateTimeImmutable::getLastErrors();
980 if ( false === $date || ( false !== $errors && ( 0 < $errors['warning_count'] || 0 < $errors['error_count'] ) ) ) {
981 return null;
982 }
983
984 return $date->format( 'Y-m-d' ) === (string) $raw_date ? $date->format( 'Y-m-d' ) : null;
985 }
986
987 /**
988 * Resolve one scope against the current authorized Resource allow-list.
989 *
990 * @param mixed $scope Untrusted scope.
991 * @param int[] $resource_ids Authorized Resource IDs.
992 *
993 * @return int[]|WP_Error Target Resource IDs or an error.
994 */
995 private function resolve_scope_resource_ids( $scope, array $resource_ids ) {
996 $scope = is_scalar( $scope ) ? sanitize_text_field( (string) $scope ) : '';
997 if ( 'all' === $scope ) {
998 return $resource_ids;
999 }
1000 if ( ! preg_match( '/^\d+$/', $scope ) ) {
1001 return new WP_Error( 'wpbc_setup_wizard_days_off_scope_invalid', __( 'Choose a valid booking item.', 'booking' ) );
1002 }
1003
1004 $resource_id = absint( $scope );
1005 if ( ! in_array( $resource_id, $resource_ids, true ) ) {
1006 return new WP_Error( 'wpbc_setup_wizard_days_off_scope_forbidden', __( 'You are not allowed to change availability for that booking item.', 'booking' ) );
1007 }
1008
1009 return array( $resource_id );
1010 }
1011
1012 /**
1013 * Return only dates from an inclusive range.
1014 *
1015 * @param string[] $dates Source dates.
1016 * @param string $first_date First date.
1017 * @param string $last_date Last date.
1018 *
1019 * @return string[] Filtered dates.
1020 */
1021 private function filter_dates_to_range( array $dates, $first_date, $last_date ) {
1022 return array_values(
1023 array_filter(
1024 $dates,
1025 static function ( $date ) use ( $first_date, $last_date ) {
1026 return $date >= $first_date && $date <= $last_date;
1027 }
1028 )
1029 );
1030 }
1031
1032 /**
1033 * Restore exact unavailable before-images after a partial write failure.
1034 *
1035 * @param int[] $resource_ids Attempted Resource IDs.
1036 * @param array<int,string[]> $before_images Prior unavailable dates.
1037 * @param string $dates_selection Complete attempted range.
1038 *
1039 * @return bool True when every compensating write succeeds.
1040 */
1041 private function restore_before_images( array $resource_ids, array $before_images, $dates_selection ) {
1042 $compensation_succeeded = true;
1043
1044 foreach ( $resource_ids as $resource_id ) {
1045 $clear_result = wpbc_availability__update_dates_status__sql(
1046 array(
1047 'resource_id' => $resource_id,
1048 'prop_name' => 'date_status',
1049 'prop_value' => 'available',
1050 'dates_selection' => $dates_selection,
1051 )
1052 );
1053 if ( false === $clear_result ) {
1054 $compensation_succeeded = false;
1055 }
1056 if ( ! empty( $before_images[ $resource_id ] ) ) {
1057 $restore_result = wpbc_availability__update_dates_status__sql(
1058 array(
1059 'resource_id' => $resource_id,
1060 'prop_name' => 'date_status',
1061 'prop_value' => 'unavailable',
1062 'dates_selection' => implode( ', ', $before_images[ $resource_id ] ),
1063 )
1064 );
1065 if ( false === $restore_result ) {
1066 $compensation_succeeded = false;
1067 }
1068 }
1069 }
1070 $this->invalidate_availability_cache();
1071
1072 return $compensation_succeeded;
1073 }
1074
1075 /**
1076 * Build a current-user/site-bound revision for stale-write protection.
1077 *
1078 * @param array<int,string[]> $dates_by_resource Canonical dates.
1079 *
1080 * @return string Hexadecimal revision.
1081 */
1082 private function get_state_revision( array $dates_by_resource ) {
1083 $context = WPBC_Setup_Wizard_Access::get_storage_context();
1084 $dates_by_resource = $this->normalize_unavailable_dates( $dates_by_resource );
1085
1086 return hash_hmac(
1087 'sha256',
1088 wp_json_encode( array( 'context' => $context, 'dates' => $dates_by_resource ) ),
1089 wp_salt( 'nonce' )
1090 );
1091 }
1092
1093 /**
1094 * Invalidate the request-local cache after canonical Availability writes.
1095 *
1096 * @return void
1097 */
1098 private function invalidate_availability_cache() {
1099 global $wpbc_global_cache;
1100
1101 if ( isset( $wpbc_global_cache['wpbc_availability__get_dates_status__sql'] ) ) {
1102 unset( $wpbc_global_cache['wpbc_availability__get_dates_status__sql'] );
1103 }
1104 }
1105 }
1106