PluginProbe
Booking Calendar / 11.8.4
Booking Calendar v11.8.4
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 10.11 All 204 releases
booking / includes / page-appointment-services / appointment_services__booking.php

appointment_services__booking.php in Booking Calendar 11.8.4, at includes/page-appointment-services/appointment_services__booking.php

1,049 lines 48.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /** Appointment Service integration with the existing booking form and save pipeline. @package Booking Calendar */
3 if ( ! defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 /**
8 * Determine whether the resource-specific frontend Service adapter is enabled.
9 *
10 * This compatibility adapter is disabled by default. Extensions or a future
11 * explicit setting may enable it through the existing filter. The dedicated
12 * [booking_appointment] controller remains the primary public workflow.
13 *
14 * @return bool True when the Service selector integration may run.
15 */
16 function wpbc_appointment_services_frontend_is_enabled() {
17 $enabled = false;
18
19 return (bool) apply_filters( 'wpbc_appointment_services_frontend_is_enabled', $enabled );
20 }
21
22 /**
23 * Manage the request-local Service context used while rendering one Appointment form.
24 *
25 * The Appointment controller sets this context immediately before the native
26 * booking form is rendered and clears it immediately afterwards. Keeping the
27 * context server-side prevents a normal booking form from inventing a Service.
28 *
29 * @param string $operation Context operation: get, set, or clear.
30 * @param array<string,mixed> $service Validated Service context for a set operation.
31 *
32 * @return array{service_id:int,resource_id:int,title:string}|array{} Current normalized context, or an empty array.
33 */
34 function wpbc_appointment_services_form_hint_context( $operation = 'get', $service = array() ) {
35 static $service_context = array();
36
37 if ( 'clear' === $operation ) {
38 $service_context = array();
39 } elseif ( 'set' === $operation ) {
40 $service_context = array(
41 'service_id' => absint( isset( $service['service_id'] ) ? $service['service_id'] : 0 ),
42 'resource_id' => absint( isset( $service['resource_id'] ) ? $service['resource_id'] : 0 ),
43 'title' => sanitize_text_field( isset( $service['title'] ) ? $service['title'] : '' ),
44 );
45 }
46
47 return $service_context;
48 }
49
50 /**
51 * Replace the Service Hint shortcode in a rendered booking form.
52 *
53 * Appointment forms receive a visible title plus a form field so the value can
54 * participate in the standard booking-data pipeline. All other forms receive
55 * an empty string. Repeated hints display the same title but only the first one
56 * emits the field that is submitted with the booking.
57 *
58 * @param string $form_html Rendered booking form markup.
59 * @param int $resource_id Booking resource used by the form.
60 * @param string $form_slug Booking Form slug used by the renderer.
61 *
62 * @return string Form markup with Service Hint shortcodes replaced.
63 */
64 function wpbc_appointment_services_replace_service_title_hint( $form_html, $resource_id, $form_slug ) {
65 $shortcode = '[service_title_hint]';
66 if ( false === strpos( $form_html, $shortcode ) ) {
67 return $form_html;
68 }
69
70 $service_context = wpbc_appointment_services_form_hint_context();
71 $resource_id = absint( $resource_id );
72 $service_title = isset( $service_context['title'] ) ? sanitize_text_field( $service_context['title'] ) : '';
73 if ( '' === $service_title || $resource_id !== absint( isset( $service_context['resource_id'] ) ? $service_context['resource_id'] : 0 ) ) {
74 return str_replace( $shortcode, '', $form_html );
75 }
76
77 $hint_id = 'service_title_hint_tip' . $resource_id;
78 $input_name = 'service_title_hint' . $resource_id;
79 $first_html = '<span class="wpbc_field_hint wpbc_appointment_service_hint" id="' . esc_attr( $hint_id ) . '">' . esc_html( $service_title ) . '</span>'
80 . '<input class="wpbc_field_hint wpbc_appointment_service_hint" id="' . esc_attr( $input_name ) . '" name="' . esc_attr( $input_name ) . '" value="' . esc_attr( $service_title ) . '" style="display:none;" type="text" />';
81
82 $form_html = preg_replace_callback(
83 '/\[service_title_hint\]/',
84 static function () use ( $first_html ) {
85 return $first_html;
86 },
87 $form_html,
88 1
89 );
90
91 $repeated_html = '<span class="wpbc_field_hint wpbc_appointment_service_hint service_title_hint_tip' . $resource_id . '">' . esc_html( $service_title ) . '</span>';
92
93 return str_replace( $shortcode, $repeated_html, $form_html );
94 }
95 add_filter( 'wpbc_replace_shortcodes_in_booking_form', 'wpbc_appointment_services_replace_service_title_hint', 30, 3 );
96
97 /**
98 * Synchronize Service Hint values with a repository-validated Appointment Service.
99 *
100 * Submitted Service Hint fields are deliberately removed first because browser
101 * form values are untrusted. A value is restored only when the core booking
102 * pipeline supplies the Service record that passed the signed Appointment
103 * context and resource-assignment checks.
104 *
105 * @param array<string,mixed> $structured_booking_data Values-only booking data.
106 * @param array<string,mixed> $all_booking_data Complete parsed booking fields.
107 * @param array<string,mixed> $appointment_service Validated Appointment Service, or an empty array.
108 * @param int $resource_id Submitted booking resource ID.
109 *
110 * @return array{structured_booking_data:array<string,mixed>,all_booking_data:array<string,mixed>} Trusted booking data.
111 */
112 function wpbc_appointment_services_sync_service_hint_booking_data( $structured_booking_data, $all_booking_data, $appointment_service, $resource_id ) {
113 unset( $structured_booking_data['service_title_hint'], $all_booking_data['service_title_hint'] );
114
115 $service_title = sanitize_text_field( isset( $appointment_service['title'] ) ? $appointment_service['title'] : '' );
116 if ( '' === $service_title ) {
117 return array(
118 'structured_booking_data' => $structured_booking_data,
119 'all_booking_data' => $all_booking_data,
120 );
121 }
122
123 $resource_id = absint( $resource_id );
124 $structured_booking_data['service_title_hint'] = $service_title;
125 $all_booking_data['service_title_hint'] = array(
126 'type' => 'text',
127 'original_name' => 'service_title_hint' . $resource_id,
128 'name' => 'service_title_hint',
129 'value' => $service_title,
130 );
131
132 return array(
133 'structured_booking_data' => $structured_booking_data,
134 'all_booking_data' => $all_booking_data,
135 );
136 }
137
138 /**
139 * Insert compatible Services before an existing resource-specific booking form.
140 *
141 * @param string $form_html Existing booking form markup.
142 * @param mixed $form_settings Existing form settings supplied by the filter.
143 * @param int $resource_id Booking resource acting as the Provider.
144 * @param string $custom_form Requested custom form name.
145 *
146 * @return string Filtered booking form markup.
147 */
148 function wpbc_appointment_services_add_frontend_selector( $form_html, $form_settings, $resource_id, $custom_form ) {
149 if ( ! wpbc_appointment_services_frontend_is_enabled() ) {
150 return $form_html;
151 }
152 $repository = wpbc_appointment_services_repository();
153 $services = $repository->list_active_for_resource( $resource_id );
154 if ( empty( $services ) ) {
155 return $form_html;
156 }
157 $select_id = 'wpbc_appointment_service_' . absint( $resource_id );
158 $html = '<div class="wpbc_appointment_service_selector" data-resource-id="' . absint( $resource_id ) . '">';
159 $html .= '<label for="' . esc_attr( $select_id ) . '">' . esc_html__( 'Service', 'booking' ) . '</label>';
160 $html .= '<select id="' . esc_attr( $select_id ) . '" class="wpbc_appointment_service_select" required>';
161 if ( count( $services ) > 1 ) {
162 $html .= '<option value="">' . esc_html__( 'Select a Service', 'booking' ) . '</option>';
163 }
164 foreach ( $services as $service ) {
165 $details = sprintf( _n( '%d minute', '%d minutes', absint( $service['duration_minutes'] ), 'booking' ), absint( $service['duration_minutes'] ) );
166 $context_token = function_exists( 'wpbc_booking_appointment_encode_submission_context' )
167 ? wpbc_booking_appointment_encode_submission_context( array(), $service['service_id'], $resource_id )
168 : '';
169 $html .= '<option value="' . absint( $service['service_id'] ) . '" data-duration="' . absint( $service['duration_minutes'] ) . '" data-appointment-context-token="' . esc_attr( $context_token ) . '">' . esc_html( $service['title'] . ' — ' . $details ) . '</option>';
170 }
171 $html .= '</select><p class="wpbc_appointment_service_summary" aria-live="polite"></p></div>';
172
173 return $html . $form_html;
174 }
175
176 add_filter( 'wpbc_booking_form__html__before_wrapper', 'wpbc_appointment_services_add_frontend_selector', 20, 4 );
177
178 /**
179 * Enqueue the resource-specific Service selector booking adapter.
180 *
181 * @param string $where_to_load Booking Calendar asset context.
182 *
183 * @return void
184 */
185 function wpbc_appointment_services_enqueue_frontend_js( $where_to_load ) {
186 if ( ! in_array( $where_to_load, array(
187 'client',
188 'both',
189 ), true ) || ! wpbc_appointment_services_frontend_is_enabled() ) {
190 return;
191 }
192 $base = trailingslashit( plugins_url( '', __FILE__ ) );
193 wp_enqueue_script( 'wpbc-appointment-services-client', $base . '_out/appointment_services_client.js', array(
194 'jquery',
195 'wpbc_capacity',
196 ), WP_BK_VERSION_NUM, array( 'in_footer' => WPBC_JS_IN_FOOTER ) );
197 }
198
199 add_action( 'wpbc_enqueue_js_files', 'wpbc_appointment_services_enqueue_frontend_js', 70 );
200
201 /**
202 * Enqueue Service selector styling for public forms and admin previews.
203 *
204 * @param string $where_to_load Booking Calendar asset context.
205 *
206 * @return void
207 */
208 function wpbc_appointment_services_enqueue_frontend_css( $where_to_load ) {
209 $is_admin_preview = 'admin' === $where_to_load && function_exists( 'wpbc_is_admin_page_with_frontend_booking_preview' ) && wpbc_is_admin_page_with_frontend_booking_preview();
210 if (
211 ( ! in_array( $where_to_load, array(
212 'client',
213 'both',
214 ), true ) && ! $is_admin_preview ) || ! wpbc_appointment_services_frontend_is_enabled() ) {
215 return;
216 }
217 wp_enqueue_style( 'wpbc-appointment-services-client', trailingslashit( plugins_url( '', __FILE__ ) ) . '_out/appointment_services_client.css', array( 'wpbc-all-client' ), WP_BK_VERSION_NUM );
218 }
219
220 add_action( 'wpbc_enqueue_css_files', 'wpbc_appointment_services_enqueue_frontend_css', 70 );
221
222 /**
223 * Persist an immutable Service snapshot after the core booking save succeeds.
224 *
225 * @param int $booking_id Saved booking ID.
226 * @param array $create_params Normalized booking creation parameters.
227 * @param string $where_to_save_booking Booking save context supplied by core.
228 *
229 * @return void
230 */
231 function wpbc_appointment_services_after_booking_save( $booking_id, $create_params, $where_to_save_booking ) {
232 if ( empty( $create_params['appointment_service'] ) || empty( $create_params['resource_id'] ) ) {
233 return;
234 }
235 $saved = wpbc_appointment_services_repository()->save_appointment_snapshot( $booking_id, $create_params['resource_id'], $create_params['appointment_service'] );
236 if ( ! $saved ) {
237 do_action( 'wpbc_appointment_snapshot_save_failed', absint( $booking_id ), $create_params, $where_to_save_booking );
238 }
239 }
240
241 add_action( 'wpbc_booking_after_save', 'wpbc_appointment_services_after_booking_save', 10, 3 );
242
243 /**
244 * Remove Appointment snapshots when their core bookings are permanently deleted.
245 *
246 * @param int|int[]|string $booking_ids Booking ID, array, or comma-separated IDs.
247 *
248 * @return void
249 */
250 function wpbc_appointment_services_delete_booking_snapshots( $booking_ids ) {
251 global $wpdb;
252 if ( ! wpbc_appointment_services_tables_exist() ) {
253 return;
254 }
255 $ids = is_array( $booking_ids ) ? $booking_ids : explode( ',', (string) $booking_ids );
256 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
257 if ( empty( $ids ) ) {
258 return;
259 }
260 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
261 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' WHERE booking_id IN (' . $placeholders . ')';
262 $wpdb->query( $wpdb->prepare( $sql, $ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
263 }
264
265 add_action( 'wpbc_booking_action__delete', 'wpbc_appointment_services_delete_booking_snapshots', 10, 1 );
266 add_action( 'wpbc_booking_delete', 'wpbc_appointment_services_delete_booking_snapshots', 10, 1 );
267
268 /**
269 * Remove Service assignments when booking resources are permanently deleted.
270 *
271 * @param int|int[]|string $resource_ids Resource ID, array, or comma-separated IDs.
272 *
273 * @return void
274 */
275 function wpbc_appointment_services_delete_resource_assignments( $resource_ids ) {
276 global $wpdb;
277 if ( ! wpbc_appointment_services_tables_exist() ) {
278 return;
279 }
280 $ids = is_array( $resource_ids ) ? $resource_ids : explode( ',', (string) $resource_ids );
281 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
282 if ( empty( $ids ) ) {
283 return;
284 }
285 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
286 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE resource_id IN (' . $placeholders . ')';
287 $wpdb->query( $wpdb->prepare( $sql, $ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
288 }
289
290 add_action( 'wpbc_deleted_booking_resources', 'wpbc_appointment_services_delete_resource_assignments', 10, 1 );
291
292 /**
293 * Return one immutable Appointment snapshot with request-local caching.
294 *
295 * @param int $booking_id Core booking ID.
296 *
297 * @return array<string,mixed>|false Snapshot row or false.
298 */
299 function wpbc_appointment_services_get_cached_snapshot( $booking_id ) {
300 static $snapshots = array();
301
302 $booking_id = absint( $booking_id );
303 if ( ! array_key_exists( $booking_id, $snapshots ) ) {
304 $snapshots[ $booking_id ] = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
305 }
306
307 return $snapshots[ $booking_id ];
308 }
309
310 /**
311 * Extract an exact Appointment interval from a core booking record.
312 *
313 * Listing records store date strings while Timeline records contain date
314 * objects. Both retain core's boundary seconds, which are normalized here.
315 *
316 * @param mixed $booking Core listing or Timeline booking record.
317 *
318 * @return array{0:int,1:int}|false Exact start/end timestamps or false.
319 */
320 function wpbc_appointment_services_get_booking_exact_interval( $booking ) {
321 if ( ! is_object( $booking ) || empty( $booking->dates ) ) {
322 return false;
323 }
324
325 $dates = array();
326 foreach ( (array) $booking->dates as $date_value ) {
327 if ( is_object( $date_value ) && isset( $date_value->booking_date ) ) {
328 $date_value = $date_value->booking_date;
329 } elseif ( is_array( $date_value ) && isset( $date_value['booking_date'] ) ) {
330 $date_value = $date_value['booking_date'];
331 }
332 if ( is_string( $date_value ) && preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date_value ) ) {
333 $dates[] = $date_value;
334 }
335 }
336 if ( empty( $dates ) ) {
337 return false;
338 }
339
340 sort( $dates );
341 return wpbc_appointment_services_normalize_stored_interval( reset( $dates ), end( $dates ) );
342 }
343
344 /**
345 * Format an exact interval for compact administrator-facing details.
346 *
347 * @param int $start_timestamp Exact start timestamp.
348 * @param int $end_timestamp Exact end timestamp.
349 *
350 * @return string Localized interval label.
351 */
352 function wpbc_appointment_services_format_interval( $start_timestamp, $end_timestamp ) {
353 $date_format = get_bk_option( 'booking_date_format' );
354 $time_format = get_bk_option( 'booking_time_format' );
355 $date_format = $date_format ? $date_format : 'm / d / Y, D';
356 $time_format = $time_format ? $time_format : 'h:i a';
357 $same_day = wpbc_datetime__no_wp_timezone( 'Y-m-d', $start_timestamp ) === wpbc_datetime__no_wp_timezone( 'Y-m-d', $end_timestamp );
358 $start = wpbc_datetime__no_wp_timezone( $same_day ? $time_format : $date_format . ' ' . $time_format, $start_timestamp );
359 $end = wpbc_datetime__no_wp_timezone( $same_day ? $time_format : $date_format . ' ' . $time_format, $end_timestamp );
360
361 return $start . ' - ' . $end;
362 }
363
364 /**
365 * Build administrator-facing time and buffer values from immutable data.
366 *
367 * @param array<string,mixed> $snapshot Immutable Appointment snapshot.
368 * @param mixed $booking Core booking record.
369 *
370 * @return array<string,mixed> Exact and formatted Appointment intervals.
371 */
372 function wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) {
373 $interval = wpbc_appointment_services_get_booking_exact_interval( $booking );
374 if ( false === $interval ) {
375 return array();
376 }
377
378 $buffer_before = absint( $snapshot['buffer_before_minutes'] );
379 $buffer_after = absint( $snapshot['buffer_after_minutes'] );
380 $reserved_start = $interval[0] - ( $buffer_before * MINUTE_IN_SECONDS );
381 $reserved_end = $interval[1] + ( $buffer_after * MINUTE_IN_SECONDS );
382
383 return array(
384 'appointment_start_timestamp' => $interval[0],
385 'appointment_end_timestamp' => $interval[1],
386 'appointment_reserved_start' => $reserved_start,
387 'appointment_reserved_end' => $reserved_end,
388 'appointment_time_label' => wpbc_appointment_services_format_interval( $interval[0], $interval[1] ),
389 'appointment_reserved_time_label' => wpbc_appointment_services_format_interval( $reserved_start, $reserved_end ),
390 );
391 }
392
393 /**
394 * Add Service identity to the existing AJAX booking-listing record.
395 *
396 * @param array<string,mixed> $fields Parsed listing fields.
397 * @param int $booking_id Booking ID.
398 * @param mixed $booking Original booking record.
399 *
400 * @return array<string,mixed> Filtered listing fields.
401 */
402 function wpbc_appointment_services_add_listing_fields( $fields, $booking_id, $booking ) {
403 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
404 if ( $snapshot ) {
405 $pricing_available = wpbc_appointment_services_is_pricing_available();
406 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
407 $fields['appointment_service_id'] = absint( $snapshot['service_id'] );
408 $fields['appointment_service_title'] = sanitize_text_field( $snapshot['service_title'] );
409 $fields['appointment_duration_minutes'] = absint( $snapshot['duration_minutes'] );
410 $fields['appointment_provider_id'] = absint( $snapshot['resource_id'] );
411 $fields['appointment_provider_title'] = ! empty( $metadata['provider_title'] )
412 ? sanitize_text_field( $metadata['provider_title'] )
413 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
414 $fields['appointment_buffer_before_minutes'] = absint( $snapshot['buffer_before_minutes'] );
415 $fields['appointment_buffer_after_minutes'] = absint( $snapshot['buffer_after_minutes'] );
416 $fields['appointment_service_cost'] = $pricing_available ? number_format( (float) $snapshot['base_cost'], 2, '.', '' ) : '';
417 $fields['appointment_service_cost_formatted'] = function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
418 ? wpbc_booking_appointment_format_service_cost_text( $snapshot['base_cost'], $snapshot['resource_id'] )
419 : '';
420 $fields = array_merge( $fields, wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) );
421 }
422
423 return $fields;
424 }
425
426 add_filter( 'wpbc_booking_listing_parsed_fields', 'wpbc_appointment_services_add_listing_fields', 10, 3 );
427
428 /**
429 * Determine whether Appointment-specific Booking Listing controls are active.
430 *
431 * Appointment data remains available in every presentation mode, but its
432 * Service filter belongs only to the Appointment administration workflow.
433 *
434 * @return bool True when Appointment mode is active.
435 */
436 function wpbc_appointment_services_is_appointment_listing_mode() {
437 return function_exists( 'wpbc_booking_modes_get_selected_mode_id' )
438 && 'appointment' === wpbc_booking_modes_get_selected_mode_id();
439 }
440
441 /**
442 * Register the Service filter in the shared Booking Listing request contract.
443 *
444 * @param array<string,array<string,mixed>> $request_schema Existing request schema.
445 * @param string $structure_type Requested schema representation.
446 *
447 * @return array<string,array<string,mixed>> Extended request schema.
448 */
449 function wpbc_appointment_services_add_listing_request_rule( $request_schema, $structure_type ) {
450 $request_schema['wh_appointment_service'] = array(
451 'validate' => 'digit_or_csd',
452 'default' => array(),
453 );
454
455 return $request_schema;
456 }
457 add_filter( 'wpbc_booking_listing_request_params_schema', 'wpbc_appointment_services_add_listing_request_rule', 10, 2 );
458
459 /**
460 * Normalize scalar, array, or comma-separated Service filter values.
461 *
462 * The request sanitizer supports both scalar and array `digit_or_csd` values.
463 * This helper flattens those compatible representations into unique positive
464 * Service IDs and can restrict them to an authorized Service catalogue.
465 *
466 * @param mixed $raw_service_ids Sanitized scalar or array value.
467 * @param array<int,mixed>|null $allowed_service_ids Optional owner-visible Service IDs. Pass null to skip authorization filtering.
468 *
469 * @return array<int,int> Unique positive Service IDs.
470 */
471 function wpbc_appointment_services_normalize_listing_service_ids( $raw_service_ids, $allowed_service_ids = null ) {
472 $raw_values = is_array( $raw_service_ids ) ? $raw_service_ids : array( $raw_service_ids );
473 $service_ids = array();
474
475 foreach ( $raw_values as $raw_value ) {
476 $separated_values = is_scalar( $raw_value ) ? explode( ',', (string) $raw_value ) : array();
477 foreach ( $separated_values as $separated_value ) {
478 $service_id = absint( $separated_value );
479 if ( $service_id ) {
480 $service_ids[ $service_id ] = $service_id;
481 }
482 }
483 }
484
485 $service_ids = array_values( $service_ids );
486 if ( null !== $allowed_service_ids ) {
487 $allowed_service_ids = array_values( array_unique( array_filter( array_map( 'absint', $allowed_service_ids ) ) ) );
488 $service_ids = array_values( array_intersect( $service_ids, $allowed_service_ids ) );
489 }
490
491 return $service_ids;
492 }
493
494 /**
495 * Return owner-visible Services available to the Appointment listing filter.
496 *
497 * All statuses are intentionally included because historical Appointments must
498 * remain filterable after their Service is deactivated or archived.
499 *
500 * @return array<int,array<string,mixed>> Owner-visible Service rows.
501 */
502 function wpbc_appointment_services_get_listing_services() {
503 $repository = wpbc_appointment_services_get_data_provider();
504 $services = is_object( $repository ) && method_exists( $repository, 'list_items' )
505 ? $repository->list_items( array( 'status' => 'all' ) )
506 : array();
507
508 return is_wp_error( $services ) || ! is_array( $services ) ? array() : $services;
509 }
510
511 /**
512 * Restrict the shared Booking Listing to one or more snapshotted Appointment Services.
513 *
514 * The existing Booking Resource query remains authoritative for Provider and
515 * MultiUser ownership filtering. This additional EXISTS clause only narrows
516 * those already-authorized bookings by their immutable Appointment snapshot.
517 *
518 * @param array{where:string,args:array<int,mixed>} $query_parts Existing SQL WHERE and arguments.
519 * @param array<string,mixed> $request_params Sanitized request values.
520 * @param array<string,mixed> $params Values merged with defaults.
521 *
522 * @return array{where:string,args:array<int,mixed>} Filtered query parts.
523 */
524 function wpbc_appointment_services_filter_listing_query( $query_parts, $request_params, $params ) {
525 if ( ! wpbc_appointment_services_is_appointment_listing_mode() || ! wpbc_appointment_services_tables_exist() ) {
526 return $query_parts;
527 }
528
529 $listing_services = wpbc_appointment_services_get_listing_services();
530 $allowed_service_ids = wp_list_pluck( $listing_services, 'service_id' );
531 $service_ids = wpbc_appointment_services_normalize_listing_service_ids(
532 isset( $params['wh_appointment_service'] ) ? $params['wh_appointment_service'] : array(),
533 $allowed_service_ids
534 );
535 if ( empty( $service_ids ) || ! isset( $query_parts['where'], $query_parts['args'] ) ) {
536 return $query_parts;
537 }
538
539 $service_placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%d' ) );
540 $query_parts['where'] .= ' AND EXISTS ( SELECT 1 FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' appointment_filter WHERE appointment_filter.booking_id = bk.booking_id AND appointment_filter.service_id IN ( ' . $service_placeholders . ' ) )';
541 $query_parts['args'] = array_merge( $query_parts['args'], $service_ids );
542
543 return $query_parts;
544 }
545 add_filter( 'wpbc_booking_listing_sql_query_parts', 'wpbc_appointment_services_filter_listing_query', 10, 3 );
546
547 /**
548 * Render the owner-aware Service selector beside the existing Provider filter.
549 *
550 * @param array<string,mixed> $request_params Sanitized current filter values.
551 * @param array<string,mixed> $defaults Default filter values.
552 *
553 * @return void
554 */
555 function wpbc_appointment_services_render_listing_filter( $request_params, $defaults ) {
556 if ( ! wpbc_appointment_services_is_appointment_listing_mode() || ! wpbc_appointment_services_storage_is_ready() ) {
557 return;
558 }
559
560 $services = wpbc_appointment_services_get_listing_services();
561 $service_options = array();
562 $allowed_service_ids = array();
563 foreach ( $services as $service ) {
564 $service_id = absint( isset( $service['service_id'] ) ? $service['service_id'] : 0 );
565 $service_title = isset( $service['title'] ) ? sanitize_text_field( $service['title'] ) : '';
566 $service_status = isset( $service['status'] ) ? sanitize_key( $service['status'] ) : 'active';
567 if ( ! $service_id || '' === $service_title ) {
568 continue;
569 }
570 if ( 'active' !== $service_status ) {
571 $status_label = 'archived' === $service_status ? __( 'Archived', 'booking' ) : __( 'Inactive', 'booking' );
572 $service_title = sprintf( '%1$s (%2$s)', $service_title, $status_label );
573 }
574 $allowed_service_ids[] = $service_id;
575 $service_options[ $service_id ] = array(
576 'title' => $service_title,
577 'attr' => array( 'title' => $service_title ),
578 );
579 }
580
581 $selected_services = isset( $request_params['wh_appointment_service'] )
582 ? $request_params['wh_appointment_service']
583 : ( isset( $defaults['wh_appointment_service'] ) ? $defaults['wh_appointment_service'] : array() );
584 $selected_services = wpbc_appointment_services_normalize_listing_service_ids( $selected_services, $allowed_service_ids );
585
586 wpbc_ui_chosen_filter_enqueue_assets();
587 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Shared component escapes its complete output.
588 echo wpbc_ui_chosen_filter_get_html(
589 array(
590 'id' => 'wh_appointment_service',
591 'name' => 'wh_appointment_service',
592 'options' => $service_options,
593 'selected_values' => $selected_services,
594 'multiple' => true,
595 'placeholder' => empty( $service_options ) ? __( 'No Services', 'booking' ) : __( 'All Services', 'booking' ),
596 'clear_label' => __( 'Clear Service selection', 'booking' ),
597 'disabled' => empty( $service_options ),
598 'container_class' => 'wpbc_booking_listing__service_filter',
599 'attributes' => array( 'aria-label' => __( 'Filter Appointments by Service', 'booking' ) ),
600 'listing_param' => 'wh_appointment_service',
601 'listing_value_type' => 'integer_array',
602 'empty_request_value' => array(),
603 'clear_selected_values' => array(),
604 )
605 );
606 }
607 add_action( 'wpbc_booking_listing_toolbar_after_resources', 'wpbc_appointment_services_render_listing_filter', 10, 2 );
608
609 /**
610 * Add immutable Appointment timing to a Timeline pipeline tooltip.
611 *
612 * @param string $title Existing plain-text tooltip.
613 * @param int $booking_id Core booking ID.
614 * @param array<int,mixed> $bookings Timeline booking collection.
615 *
616 * @return string Filtered tooltip.
617 */
618 function wpbc_appointment_services_filter_timeline_pipeline_title( $title, $booking_id, $bookings ) {
619 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
620 $booking = isset( $bookings[ $booking_id ] ) ? $bookings[ $booking_id ] : null;
621 $details = $snapshot ? wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) : array();
622 if ( empty( $details ) ) {
623 return $title;
624 }
625
626 $title .= "\n" . sprintf( __( 'Service: %s', 'booking' ), sanitize_text_field( $snapshot['service_title'] ) );
627 $service_cost = function_exists( 'wpbc_booking_appointment_format_service_cost' ) ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] ) : '';
628 if ( '' !== $service_cost ) {
629 $title .= "\n" . sprintf( __( 'Service price: %s', 'booking' ), wp_strip_all_tags( $service_cost ) );
630 }
631 $title .= "\n" . sprintf( __( 'Appointment: %s', 'booking' ), $details['appointment_time_label'] );
632 $title .= "\n" . sprintf(
633 __( 'Provider reserved: %1$s (buffers %2$d / %3$d min)', 'booking' ),
634 $details['appointment_reserved_time_label'],
635 absint( $snapshot['buffer_before_minutes'] ),
636 absint( $snapshot['buffer_after_minutes'] )
637 );
638
639 return $title;
640 }
641 add_filter( 'wpbc_timeline_booking_pipeline_title', 'wpbc_appointment_services_filter_timeline_pipeline_title', 10, 3 );
642
643 /**
644 * Add Appointment timing to the administrator Timeline popover.
645 *
646 * @param array<string,string> $popover Existing popover title and content.
647 * @param int $booking_id Core booking ID.
648 * @param array<int,mixed> $bookings Timeline booking collection.
649 * @param bool $is_frontend Whether Timeline is public.
650 *
651 * @return array<string,string> Filtered popover.
652 */
653 function wpbc_appointment_services_filter_timeline_popover( $popover, $booking_id, $bookings, $is_frontend ) {
654 if ( $is_frontend ) {
655 return $popover;
656 }
657
658 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
659 $booking = isset( $bookings[ $booking_id ] ) ? $bookings[ $booking_id ] : null;
660 $details = $snapshot ? wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) : array();
661 if ( empty( $details ) ) {
662 return $popover;
663 }
664
665 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
666 $provider_title = ! empty( $metadata['provider_title'] )
667 ? sanitize_text_field( $metadata['provider_title'] )
668 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
669 $service_cost = function_exists( 'wpbc_booking_appointment_format_service_cost' ) ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] ) : '';
670 $popover['content'] .= '<div class="wpbc_timeline_appointment_details">'
671 . '<strong>' . esc_html__( 'Appointment', 'booking' ) . '</strong><br>'
672 . esc_html__( 'Service', 'booking' ) . ': ' . esc_html( $snapshot['service_title'] ) . '<br>'
673 . esc_html__( 'Provider', 'booking' ) . ': ' . esc_html( $provider_title ) . '<br>'
674 . ( '' !== $service_cost ? esc_html__( 'Service price', 'booking' ) . ': ' . wp_kses_post( $service_cost ) . '<br>' : '' )
675 . esc_html__( 'Appointment time', 'booking' ) . ': ' . esc_html( $details['appointment_time_label'] ) . '<br>'
676 . esc_html__( 'Buffer before / after', 'booking' ) . ': ' . absint( $snapshot['buffer_before_minutes'] ) . ' / ' . absint( $snapshot['buffer_after_minutes'] ) . ' ' . esc_html__( 'min', 'booking' ) . '<br>'
677 . esc_html__( 'Provider reserved', 'booking' ) . ': ' . esc_html( $details['appointment_reserved_time_label'] )
678 . '</div>';
679
680 return $popover;
681 }
682 add_filter( 'wpbc_timeline_booking_popover', 'wpbc_appointment_services_filter_timeline_popover', 10, 4 );
683
684 /**
685 * Add immutable Appointment values to email and confirmation replacements.
686 *
687 * Available shortcodes are `[service_title]`, `[service_title_hint]`, `[service_duration]`,
688 * `[service_duration_minutes]`, `[provider_title]`, and
689 * `[appointment_summary]`. Non-Appointment bookings retain their existing
690 * replacement collection unchanged.
691 *
692 * @param array<string,mixed> $replace Existing replacement values.
693 * @param int $booking_id Core booking ID.
694 * @param int $bktype Booking resource ID.
695 * @param string $formdata Stored booking form data.
696 *
697 * @return array<string,mixed> Replacement values with Appointment context.
698 */
699 function wpbc_appointment_services_add_replace_params( $replace, $booking_id, $bktype, $formdata ) {
700 $replace['service_title_hint'] = '';
701 $snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
702 if ( ! $snapshot ) {
703 return $replace;
704 }
705
706 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
707 $service_title = sanitize_text_field( $snapshot['service_title'] );
708 $provider_title = ! empty( $metadata['provider_title'] )
709 ? sanitize_text_field( $metadata['provider_title'] )
710 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
711 $duration = function_exists( 'wpbc_booking_appointment_format_duration' )
712 ? wpbc_booking_appointment_format_duration( $snapshot['duration_minutes'] )
713 : sprintf( _n( '%d minute', '%d minutes', absint( $snapshot['duration_minutes'] ), 'booking' ), absint( $snapshot['duration_minutes'] ) );
714 $pricing_available = wpbc_appointment_services_is_pricing_available();
715 $service_cost_digits = $pricing_available ? number_format( (float) $snapshot['base_cost'], 2, '.', '' ) : '';
716 $service_cost = $pricing_available && function_exists( 'wpbc_booking_appointment_format_service_cost' )
717 ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] )
718 : '';
719
720 $replace['service_title'] = $service_title;
721 $replace['service_title_hint'] = $service_title;
722 $replace['service_duration'] = $duration;
723 $replace['service_duration_minutes'] = absint( $snapshot['duration_minutes'] );
724 $replace['service_cost'] = $service_cost;
725 $replace['service_cost_digits_only'] = $service_cost_digits;
726 $replace['provider_title'] = $provider_title;
727 $summary_parts = array( $service_title, $provider_title, $duration );
728 if ( '' !== $service_cost ) {
729 $summary_parts[] = function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
730 ? wpbc_booking_appointment_format_service_cost_text( $snapshot['base_cost'], $snapshot['resource_id'] )
731 : $service_cost_digits;
732 }
733 $replace['appointment_summary'] = implode( ' · ', $summary_parts );
734
735 return $replace;
736 }
737
738 add_filter( 'wpbc_replace_params_for_booking', 'wpbc_appointment_services_add_replace_params', 20, 4 );
739
740 /**
741 * Document Appointment replacement shortcodes in the existing email help UI.
742 *
743 * @param array<int,string> $fields Existing email help rows.
744 * @param array<int,string> $skip_shortcodes Shortcodes hidden by the email type.
745 * @param string $email_example Existing example text.
746 *
747 * @return array<int,string> Help rows including Appointment replacements.
748 */
749 function wpbc_appointment_services_add_email_help_shortcodes( $fields, $skip_shortcodes, $email_example ) {
750 $fields[] = '<hr/>';
751 $fields[] = '<strong>' . esc_html__( 'Appointment details', 'booking' ) . '</strong>';
752 $fields[] = '<code>[service_title_hint]</code> - ' . esc_html__( 'Service Hint value saved with the Appointment; empty for other bookings.', 'booking' );
753 $fields[] = '<code>[service_title]</code> — ' . esc_html__( 'Service title saved with the Appointment.', 'booking' );
754 $fields[] = '<code>[service_duration]</code> — ' . esc_html__( 'Formatted Service duration.', 'booking' );
755 $fields[] = '<code>[service_duration_minutes]</code> — ' . esc_html__( 'Service duration in minutes.', 'booking' );
756 if ( wpbc_appointment_services_is_pricing_available() ) {
757 $fields[] = '<code>[service_cost]</code> — ' . esc_html__( 'Effective Service price with currency.', 'booking' );
758 $fields[] = '<code>[service_cost_digits_only]</code> — ' . esc_html__( 'Effective Service price without currency.', 'booking' );
759 }
760 $fields[] = '<code>[provider_title]</code> — ' . esc_html__( 'Provider title saved with the Appointment.', 'booking' );
761 $fields[] = '<code>[appointment_summary]</code> — ' . esc_html__( 'Service, Provider, and duration in one line.', 'booking' );
762
763 return $fields;
764 }
765
766 add_filter( 'wpbc_email_help_shortcodes', 'wpbc_appointment_services_add_email_help_shortcodes', 20, 3 );
767
768 /**
769 * Document Appointment values in the Payment Description help panel.
770 *
771 * @param array<int,string> $fields Existing payment-description help rows.
772 *
773 * @return array<int,string> Help rows including Appointment replacements.
774 */
775 function wpbc_appointment_services_add_payment_help_shortcodes( $fields ) {
776 $fields[] = '<hr/><strong>' . esc_html__( 'Appointment details', 'booking' ) . '</strong>';
777 $service_cost_shortcode = wpbc_appointment_services_is_pricing_available() ? ', <code>[service_cost]</code>' : '';
778 $fields[] = '<code>[service_title]</code>, <code>[service_title_hint]</code>, <code>[service_duration]</code>' . $service_cost_shortcode . ', <code>[provider_title]</code>, <code>[appointment_summary]</code>';
779
780 return $fields;
781 }
782 add_filter( 'wpbc_payment_help_shortcodes', 'wpbc_appointment_services_add_payment_help_shortcodes', 20, 1 );
783
784 /**
785 * Return the snapshotted Service ID for a booking.
786 *
787 * @param int $booking_id Booking ID.
788 *
789 * @return int Service ID, or zero for a non-Appointment booking.
790 */
791 function wpbc_appointment_services_get_booking_service_id( $booking_id ) {
792 $snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
793
794 return $snapshot ? absint( $snapshot['service_id'] ) : 0;
795 }
796
797 /**
798 * Prevent moving an Appointment to a Provider who cannot perform its Service.
799 *
800 * Non-Appointment bookings preserve the incoming validation result. Appointment
801 * bookings return WP_Error when the target resource lacks an active assignment.
802 * Core may deliberately bypass this filter through its force-change setting.
803 *
804 * @param true|WP_Error $valid Validation result from earlier callbacks.
805 * @param int $booking_id Booking being moved.
806 * @param int $resource_id Target Provider resource ID.
807 *
808 * @return true|WP_Error Incoming result or a Service/Provider mismatch error.
809 */
810 function wpbc_appointment_services_validate_resource_change( $valid, $booking_id, $resource_id ) {
811 $service_id = wpbc_appointment_services_get_booking_service_id( $booking_id );
812 if ( ! $service_id ) {
813 return $valid;
814 }
815 $service = wpbc_appointment_services_repository()->find_active_for_resource( $service_id, $resource_id );
816
817 return is_wp_error( $service ) ? $service : $valid;
818 }
819
820 add_filter( 'wpbc_booking_validate_resource_change', 'wpbc_appointment_services_validate_resource_change', 10, 3 );
821
822 /**
823 * Keep the Appointment snapshot aligned after a successful resource move.
824 *
825 * @param int $booking_id Moved booking ID.
826 * @param int $resource_id New Provider resource ID.
827 *
828 * @return void
829 */
830 function wpbc_appointment_services_after_resource_change( $booking_id, $resource_id ) {
831 if ( wpbc_appointment_services_get_booking_service_id( $booking_id ) ) {
832 wpbc_appointment_services_repository()->update_snapshot_resource( $booking_id, $resource_id );
833 }
834 }
835
836 add_action( 'wpbc_booking_action__change_booking_resource', 'wpbc_appointment_services_after_resource_change', 10, 2 );
837
838 /**
839 * Resolve the server-authoritative end time for an Appointment Service.
840 *
841 * @param array<string,mixed> $service Effective Service values.
842 * @param int $start_seconds Selected start time as seconds in the day.
843 * @param int $maximum_duration_minutes Maximum allowed duration in minutes.
844 *
845 * @return int|WP_Error End time as seconds in the day, or a validation error.
846 */
847 function wpbc_appointment_services_resolve_end_seconds( $service, $start_seconds, $maximum_duration_minutes = 1440 ) {
848 $duration_minutes = ! empty( $service['duration_minutes'] ) ? absint( $service['duration_minutes'] ) : 0;
849 $maximum_duration_minutes = absint( $maximum_duration_minutes );
850 if ( ! $duration_minutes || ( $maximum_duration_minutes && $duration_minutes > $maximum_duration_minutes ) ) {
851 return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service duration is invalid. Please contact the website administrator.', 'booking' ) );
852 }
853
854 $end_seconds = absint( $start_seconds ) + ( $duration_minutes * MINUTE_IN_SECONDS );
855 if ( $end_seconds > DAY_IN_SECONDS ) {
856 return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service does not fit in the chosen day. Please select an earlier start time.', 'booking' ) );
857 }
858
859 return $end_seconds;
860 }
861
862 /**
863 * Convert Booking Calendar's stored boundary markers to exact interval times.
864 *
865 * Core stores timed starts with `+1` second and ends with `+2` seconds. An end
866 * at midnight is represented as `23:59:52`. Buffer comparison must remove
867 * those internal markers or adjacent zero-buffer Appointments look overlapped.
868 *
869 * @param string $starts_at Stored SQL start datetime.
870 * @param string $ends_at Stored SQL end datetime.
871 *
872 * @return array{0:int,1:int} Exact start and end timestamps.
873 */
874 function wpbc_appointment_services_normalize_stored_interval( $starts_at, $ends_at ) {
875 $start_timestamp = wpbc_convert__sql_date__to_seconds( $starts_at, false );
876 $end_timestamp = wpbc_convert__sql_date__to_seconds( $ends_at, false );
877 $start_time = substr( (string) $starts_at, -8 );
878 $end_time = substr( (string) $ends_at, -8 );
879
880 if ( '01' === substr( $start_time, -2 ) ) {
881 $start_timestamp--;
882 }
883 if ( '02' === substr( $end_time, -2 ) ) {
884 $end_timestamp -= 2;
885 } elseif ( '23:59:52' === $end_time ) {
886 $end_timestamp += 8;
887 }
888
889 return array( $start_timestamp, $end_timestamp );
890 }
891
892 /**
893 * Determine whether two half-open scheduling intervals overlap.
894 *
895 * @param int $left_start First interval start timestamp.
896 * @param int $left_end First interval end timestamp.
897 * @param int $right_start Second interval start timestamp.
898 * @param int $right_end Second interval end timestamp.
899 *
900 * @return bool True only when the intervals overlap; touching boundaries pass.
901 */
902 function wpbc_appointment_services_intervals_overlap( $left_start, $left_end, $right_start, $right_end ) {
903 return (int) $left_start < (int) $right_end && (int) $left_end > (int) $right_start;
904 }
905
906 /**
907 * Check one exact Appointment interval against buffered existing intervals.
908 *
909 * Existing rows must contain exact Unix timestamps in `start` and `end` plus
910 * optional `buffer_before_minutes` and `buffer_after_minutes` values. Keeping
911 * this calculation independent from SQL lets the save path, AJAX preflight,
912 * and browser test panel exercise the same boundary rules.
913 *
914 * @param int $new_start Exact new start timestamp.
915 * @param int $new_end Exact new end timestamp.
916 * @param int $new_buffer_before New Service buffer before in minutes.
917 * @param int $new_buffer_after New Service buffer after in minutes.
918 * @param array<int,array<string,int>> $existing_intervals Exact existing intervals and buffers.
919 *
920 * @return bool True when any buffered interval overlaps.
921 */
922 function wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_buffer_before, $new_buffer_after, $existing_intervals ) {
923 $new_start = (int) $new_start - ( absint( $new_buffer_before ) * MINUTE_IN_SECONDS );
924 $new_end = (int) $new_end + ( absint( $new_buffer_after ) * MINUTE_IN_SECONDS );
925 if ( $new_end <= $new_start ) {
926 return false;
927 }
928
929 foreach ( (array) $existing_intervals as $existing_interval ) {
930 $old_start = isset( $existing_interval['start'] ) ? (int) $existing_interval['start'] : 0;
931 $old_end = isset( $existing_interval['end'] ) ? (int) $existing_interval['end'] : 0;
932 if ( ! $old_start || $old_end <= $old_start ) {
933 continue;
934 }
935 $old_start -= ( isset( $existing_interval['buffer_before_minutes'] ) ? absint( $existing_interval['buffer_before_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
936 $old_end += ( isset( $existing_interval['buffer_after_minutes'] ) ? absint( $existing_interval['buffer_after_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
937 if ( wpbc_appointment_services_intervals_overlap( $new_start, $new_end, $old_start, $old_end ) ) {
938 return true;
939 }
940 }
941
942 return false;
943 }
944
945 /**
946 * Load exact buffered intervals for one Provider in one bounded date range.
947 *
948 * One query is intentionally shared by the selected-time preflight, the bulk
949 * Start Time filter, and final save validation. Appointment snapshots preserve
950 * the buffers that applied when an existing booking was created. The 46-day
951 * SQL margin covers the complete unsigned SMALLINT minute range used by the
952 * existing Service schema, including legacy values larger than one day.
953 *
954 * @param int $resource_id Provider resource ID.
955 * @param string[] $dates Selected SQL dates.
956 * @param int $skip_booking_id Optional booking excluded during an update.
957 *
958 * @return array<int,array<string,int>> Existing exact intervals and buffers.
959 */
960 function wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $dates, $skip_booking_id = 0 ) {
961 global $wpdb;
962
963 $date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
964 if ( ! absint( $resource_id ) || empty( $date_values ) ) {
965 return array();
966 }
967
968 $range_start = min( $date_values );
969 $range_end = max( $date_values );
970 $sql = "SELECT b.booking_id, DATE(bd.booking_date) AS appointment_date, MIN(bd.booking_date) AS starts_at, MAX(bd.booking_date) AS ends_at,
971 COALESCE(ad.buffer_before_minutes,0) AS buffer_before_minutes,
972 COALESCE(ad.buffer_after_minutes,0) AS buffer_after_minutes
973 FROM {$wpdb->prefix}booking b
974 INNER JOIN {$wpdb->prefix}bookingdates bd ON bd.booking_id = b.booking_id
975 LEFT JOIN " . wpbc_appointment_services_table_name( 'appointment_details' ) . " ad ON ad.booking_id = b.booking_id
976 WHERE b.booking_type = %d AND b.booking_id <> %d AND b.trash = 0
977 AND DATE(bd.booking_date) BETWEEN DATE_SUB(%s, INTERVAL 46 DAY) AND DATE_ADD(%s, INTERVAL 46 DAY)
978 GROUP BY b.booking_id, DATE(bd.booking_date)";
979 $existing = $wpdb->get_results( $wpdb->prepare( $sql, absint( $resource_id ), absint( $skip_booking_id ), $range_start, $range_end ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
980 $intervals = array();
981
982 foreach ( (array) $existing as $booking ) {
983 list( $old_start, $old_end ) = wpbc_appointment_services_normalize_stored_interval( $booking['starts_at'], $booking['ends_at'] );
984 $intervals[] = array(
985 'booking_id' => absint( $booking['booking_id'] ),
986 'start' => $old_start,
987 'end' => $old_end,
988 'buffer_before_minutes' => absint( $booking['buffer_before_minutes'] ),
989 'buffer_after_minutes' => absint( $booking['buffer_after_minutes'] ),
990 );
991 }
992
993 return $intervals;
994 }
995
996 /**
997 * Check Service buffers against an already loaded interval collection.
998 *
999 * @param array $service Effective Service definition.
1000 * @param string[] $dates Selected SQL dates.
1001 * @param int[] $time_seconds Exact start and end seconds in the day.
1002 * @param array<int,array<string,int>> $existing_intervals Existing Provider intervals.
1003 *
1004 * @return true|WP_Error True when the requested interval is available.
1005 */
1006 function wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $dates, $time_seconds, $existing_intervals ) {
1007 if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
1008 return true;
1009 }
1010
1011 $new_before = isset( $service['buffer_before_minutes'] ) ? absint( $service['buffer_before_minutes'] ) : 0;
1012 $new_after = isset( $service['buffer_after_minutes'] ) ? absint( $service['buffer_after_minutes'] ) : 0;
1013 foreach ( (array) $dates as $date_value ) {
1014 $date_value = sanitize_text_field( $date_value );
1015 $new_start = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[0] ), false );
1016 $new_end = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[1] ), false );
1017 if ( wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_before, $new_after, $existing_intervals ) ) {
1018 return new WP_Error( 'appointment_service_buffer_conflict', __( 'This start time is unavailable because the Service duration or required buffer overlaps another appointment. Please choose another time.', 'booking' ) );
1019 }
1020 }
1021
1022 return true;
1023 }
1024
1025 /**
1026 * Check Service buffers against existing bookings after the core availability
1027 * engine has selected the actual Provider resource.
1028 *
1029 * @param array $service Selected Service definition.
1030 * @param int $resource_id Provider resource ID.
1031 * @param array $dates Selected booking dates.
1032 * @param array $time_seconds Start and end time expressed as day seconds.
1033 * @param int $skip_booking_id Optional booking excluded during an update.
1034 *
1035 * @return true|WP_Error True when buffers do not overlap, otherwise a conflict error.
1036 */
1037 function wpbc_appointment_services_check_buffer_conflicts( $service, $resource_id, $dates, $time_seconds, $skip_booking_id = 0 ) {
1038 if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
1039 return true;
1040 }
1041 $date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
1042 if ( empty( $date_values ) ) {
1043 return true;
1044 }
1045 $existing_intervals = wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $date_values, $skip_booking_id );
1046
1047 return wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $date_values, $time_seconds, $existing_intervals );
1048 }
1049