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

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

454 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Appointment Service draft integration for the modular Setup Wizard Services step.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Read and validate Service proposals for the progressive save boundary.
14 *
15 * Existing Appointment Services remain the read-only source for initial
16 * suggestions. When none are available, the activation-owned starter records
17 * provide familiar defaults. The resulting DTO deliberately excludes owner,
18 * Provider, form, and database metadata; those domains are configured and
19 * authorized by the Services save handler and later wizard steps.
20 */
21 final class WPBC_Setup_Wizard_Services {
22
23 const MAX_SERVICES = 20;
24 const MAX_DESCRIPTION_LENGTH = 2000;
25 const MAX_PICTURE_URL_LENGTH = 2048;
26 const MAX_MINUTES = 1440;
27 const MAX_BASE_COST = 1000;
28
29 /**
30 * Return the template context for the Services step.
31 *
32 * @param array<string,mixed> $step_values Validated values for this step.
33 *
34 * @return array<string,mixed> Presentation-only Service editor context.
35 */
36 public function get_context( array $step_values ) {
37 $pricing_available = $this->is_pricing_available();
38 $currency_symbol = '$';
39
40 if ( $pricing_available && function_exists( 'wpbc_get_currency_symbol' ) ) {
41 $currency_symbol = html_entity_decode(
42 wp_strip_all_tags( (string) wpbc_get_currency_symbol() ),
43 ENT_QUOTES,
44 get_bloginfo( 'charset' )
45 );
46 }
47
48 return array(
49 'values' => $step_values,
50 'services' => isset( $step_values['services'] ) && is_array( $step_values['services'] ) ? $step_values['services'] : array(),
51 'inactive_service_ids' => isset( $step_values['inactive_service_ids'] ) && is_array( $step_values['inactive_service_ids'] ) ? $step_values['inactive_service_ids'] : array(),
52 'pricing_available' => $pricing_available,
53 'currency_symbol' => sanitize_text_field( $currency_symbol ),
54 'max_services' => self::MAX_SERVICES,
55 );
56 }
57
58 /**
59 * Validate canonical Service IDs explicitly staged to move to Draft.
60 *
61 * This dedicated transport prevents an omitted proposal from being interpreted
62 * as a lifecycle mutation. Ownership and current canonical state are checked
63 * again by the progressive save handler before any status change is written.
64 *
65 * @param mixed $raw_service_ids JSON transport string or stored array.
66 *
67 * @return int[]|WP_Error Normalized unique positive Service IDs or an error.
68 */
69 public function validate_inactive_service_ids( $raw_service_ids ) {
70 if ( is_string( $raw_service_ids ) ) {
71 if ( strlen( $raw_service_ids ) > 2000 ) {
72 return new WP_Error( 'wpbc_setup_wizard_inactive_services_too_large', __( 'The Services selected for Draft are invalid.', 'booking' ) );
73 }
74
75 $raw_service_ids = json_decode( $raw_service_ids, true );
76 if ( JSON_ERROR_NONE !== json_last_error() ) {
77 return new WP_Error( 'wpbc_setup_wizard_inactive_services_invalid_json', __( 'The Services selected for Draft are invalid.', 'booking' ) );
78 }
79 }
80
81 if ( ! is_array( $raw_service_ids ) || count( $raw_service_ids ) > self::MAX_SERVICES ) {
82 return new WP_Error( 'wpbc_setup_wizard_inactive_services_invalid', __( 'The Services selected for Draft are invalid.', 'booking' ) );
83 }
84
85 $normalized_ids = array();
86 foreach ( array_values( $raw_service_ids ) as $raw_service_id ) {
87 if ( ! is_scalar( $raw_service_id ) || ! preg_match( '/^[0-9]+$/', (string) $raw_service_id ) ) {
88 return new WP_Error( 'wpbc_setup_wizard_inactive_service_id_invalid', __( 'A Service selected for Draft is invalid.', 'booking' ) );
89 }
90
91 $service_id = absint( $raw_service_id );
92 if ( ! $service_id || in_array( $service_id, $normalized_ids, true ) ) {
93 return new WP_Error( 'wpbc_setup_wizard_inactive_service_id_duplicate', __( 'Each Service can be moved to Draft only once.', 'booking' ) );
94 }
95
96 $normalized_ids[] = $service_id;
97 }
98
99 return $normalized_ids;
100 }
101
102 /**
103 * Return canonical active Service IDs currently authorized for this owner.
104 *
105 * The save handler uses this read-only allow-list to prove that an explicit
106 * Draft request originated from the Services presented by this wizard session.
107 *
108 * @return int[] Owner-authorized active Service IDs.
109 */
110 public function get_authorized_active_service_ids() {
111 $service_ids = array();
112
113 foreach ( $this->get_existing_service_rows() as $service_row ) {
114 $service_row = is_object( $service_row ) ? get_object_vars( $service_row ) : (array) $service_row;
115 $service_id = isset( $service_row['service_id'] ) ? absint( $service_row['service_id'] ) : 0;
116 if ( $service_id ) {
117 $service_ids[] = $service_id;
118 }
119 }
120
121 return array_values( array_unique( $service_ids ) );
122 }
123
124 /**
125 * Load owner-authorized canonical Services or safe activation defaults.
126 *
127 * The repository read is bounded and never persists data. Starter records
128 * are used only when no active canonical Service can be read. They remain
129 * proposals until the user chooses Create services & continue, which invokes
130 * the progressive canonical save boundary.
131 *
132 * @return array<int,array<string,mixed>> Service proposal DTOs.
133 */
134 public function get_initial_services() {
135 $service_rows = $this->get_existing_service_rows();
136
137 if ( empty( $service_rows ) ) {
138 $service_rows = $this->get_starter_service_rows();
139 }
140
141 $service_drafts = array();
142 foreach ( array_slice( $service_rows, 0, self::MAX_SERVICES ) as $service_index => $service_row ) {
143 $service_drafts[] = $this->create_service_draft( $service_row, $service_index );
144 }
145
146 return $service_drafts;
147 }
148
149 /**
150 * Validate the complete Service proposal collection from one draft field.
151 *
152 * @param mixed $raw_services JSON transport string or stored array.
153 * @param bool $require_complete Whether every Service must have a title.
154 *
155 * @return array<int,array<string,mixed>>|WP_Error Normalized proposals or an error.
156 */
157 public function validate_services( $raw_services, $require_complete ) {
158 if ( is_string( $raw_services ) ) {
159 if ( strlen( $raw_services ) > 100000 ) {
160 return new WP_Error( 'wpbc_setup_wizard_services_too_large', __( 'The Service draft is too large.', 'booking' ) );
161 }
162
163 $raw_services = json_decode( $raw_services, true );
164 if ( JSON_ERROR_NONE !== json_last_error() ) {
165 return new WP_Error( 'wpbc_setup_wizard_services_invalid_json', __( 'The Service draft is invalid.', 'booking' ) );
166 }
167 }
168
169 if ( ! is_array( $raw_services ) ) {
170 return new WP_Error( 'wpbc_setup_wizard_services_invalid', __( 'Enter valid Service details.', 'booking' ) );
171 }
172
173 if ( $require_complete && empty( $raw_services ) ) {
174 return new WP_Error( 'wpbc_setup_wizard_services_required', __( 'Add at least one Service.', 'booking' ) );
175 }
176
177 if ( count( $raw_services ) > self::MAX_SERVICES ) {
178 return new WP_Error(
179 'wpbc_setup_wizard_services_limit',
180 /* translators: %d: Maximum number of Service drafts. */
181 sprintf( __( 'Add no more than %d Services in this setup draft.', 'booking' ), self::MAX_SERVICES )
182 );
183 }
184
185 $normalized_services = array();
186 $used_draft_ids = array();
187 foreach ( array_values( $raw_services ) as $service_index => $raw_service ) {
188 $normalized_service = $this->validate_service( $raw_service, $service_index, $require_complete );
189 if ( is_wp_error( $normalized_service ) ) {
190 return $normalized_service;
191 }
192
193 if ( isset( $used_draft_ids[ $normalized_service['draft_id'] ] ) ) {
194 return new WP_Error( 'wpbc_setup_wizard_service_duplicate_key', __( 'Each Service draft must have a unique identifier.', 'booking' ) );
195 }
196
197 $used_draft_ids[ $normalized_service['draft_id'] ] = true;
198 $normalized_services[] = $normalized_service;
199 }
200
201 return $normalized_services;
202 }
203
204 /**
205 * Read existing active Services through the canonical owner-aware provider.
206 *
207 * @return array<int,array<string,mixed>> Repository rows, or an empty array.
208 */
209 private function get_existing_service_rows() {
210 if (
211 ! function_exists( 'wpbc_appointment_services_get_manage_capability' )
212 || ! current_user_can( wpbc_appointment_services_get_manage_capability() )
213 || ! function_exists( 'wpbc_appointment_services_get_data_provider' )
214 ) {
215 return array();
216 }
217
218 $service_provider = wpbc_appointment_services_get_data_provider();
219 if ( ! is_object( $service_provider ) || ! method_exists( $service_provider, 'list_items' ) ) {
220 return array();
221 }
222
223 $list_method = method_exists( $service_provider, 'list_items_for_current_owner' )
224 ? 'list_items_for_current_owner'
225 : 'list_items';
226 $service_rows = $service_provider->{$list_method}(
227 array(
228 'status' => 'active',
229 'sort_by' => 'service_id',
230 'sort_order' => 'asc',
231 'limit' => self::MAX_SERVICES,
232 'offset' => 0,
233 )
234 );
235 $owner_user_id = function_exists( 'wpbc_appointment_services_get_owner_user_id' )
236 ? absint( wpbc_appointment_services_get_owner_user_id() )
237 : 0;
238 $service_rows = is_wp_error( $service_rows ) || ! is_array( $service_rows )
239 ? array()
240 : array_values(
241 array_filter(
242 $service_rows,
243 static function ( $service_row ) use ( $owner_user_id ) {
244 $service_row = is_object( $service_row ) ? get_object_vars( $service_row ) : (array) $service_row;
245
246 return isset( $service_row['owner_user_id'] ) && $owner_user_id === absint( $service_row['owner_user_id'] );
247 }
248 )
249 );
250
251 return array_slice( $service_rows, 0, self::MAX_SERVICES );
252 }
253
254 /**
255 * Return activation-owned starter Service records without creating them.
256 *
257 * @return array<int,array<string,mixed>> Starter records.
258 */
259 private function get_starter_service_rows() {
260 $provider_id = function_exists( 'wpbc_get_default_resource' ) ? absint( wpbc_get_default_resource() ) : 1;
261 $provider_id = $provider_id ? $provider_id : 1;
262 $form_id = function_exists( 'wpbc_appointment_services_get_starter_booking_form_id' )
263 ? absint( wpbc_appointment_services_get_starter_booking_form_id() )
264 : 0;
265
266 if ( function_exists( 'wpbc_appointment_services_get_starter_services_values' ) ) {
267 return (array) wpbc_appointment_services_get_starter_services_values( $provider_id, $form_id );
268 }
269
270 return array(
271 array(
272 'title' => __( 'Initial Consultation', 'booking' ),
273 'description' => __( 'A focused first meeting to understand your needs and recommend the right next step.', 'booking' ),
274 'duration_minutes' => 30,
275 'buffer_before_minutes' => 0,
276 'buffer_after_minutes' => 0,
277 'base_cost' => '0.00',
278 'status' => 'active',
279 ),
280 );
281 }
282
283 /**
284 * Map one canonical or starter record to the wizard Service DTO.
285 *
286 * @param mixed $service_row Canonical or starter Service record.
287 * @param int $service_index Zero-based presentation index.
288 *
289 * @return array<string,mixed> JSON-safe Service draft.
290 */
291 private function create_service_draft( $service_row, $service_index ) {
292 $normalized_service = function_exists( 'wpbc_appointment_services_normalize_item' )
293 ? wpbc_appointment_services_normalize_item( $service_row )
294 : (array) $service_row;
295 $source_service_id = isset( $normalized_service['service_id'] ) ? absint( $normalized_service['service_id'] ) : 0;
296
297 return array(
298 'draft_id' => $source_service_id ? 'service-' . $source_service_id : 'draft-' . ( $service_index + 1 ),
299 'source_service_id' => $source_service_id,
300 'title' => isset( $normalized_service['title'] ) ? wp_html_excerpt( sanitize_text_field( (string) $normalized_service['title'] ), 200, '' ) : '',
301 'description' => isset( $normalized_service['description'] ) ? sanitize_textarea_field( (string) $normalized_service['description'] ) : '',
302 'picture_url' => isset( $normalized_service['picture_url'] ) ? esc_url_raw( (string) $normalized_service['picture_url'] ) : '',
303 'duration_minutes' => isset( $normalized_service['duration_minutes'] ) ? min( self::MAX_MINUTES, max( 1, absint( $normalized_service['duration_minutes'] ) ) ) : 30,
304 'buffer_before_minutes' => isset( $normalized_service['buffer_before_minutes'] ) ? min( self::MAX_MINUTES, absint( $normalized_service['buffer_before_minutes'] ) ) : 0,
305 'buffer_after_minutes' => isset( $normalized_service['buffer_after_minutes'] ) ? min( self::MAX_MINUTES, absint( $normalized_service['buffer_after_minutes'] ) ) : 0,
306 'base_cost' => $this->is_pricing_available() && isset( $normalized_service['base_cost'] ) && is_numeric( $normalized_service['base_cost'] )
307 ? number_format( min( self::MAX_BASE_COST, max( 0, (float) $normalized_service['base_cost'] ) ), 2, '.', '' )
308 : '0.00',
309 'status' => 'active',
310 );
311 }
312
313 /**
314 * Validate one Service proposal and reject unregistered properties.
315 *
316 * @param mixed $raw_service Untrusted Service value.
317 * @param int $service_index Zero-based Service index.
318 * @param bool $require_complete Whether the title is required.
319 *
320 * @return array<string,mixed>|WP_Error Normalized Service or an error.
321 */
322 private function validate_service( $raw_service, $service_index, $require_complete ) {
323 if ( ! is_array( $raw_service ) ) {
324 return new WP_Error( 'wpbc_setup_wizard_service_invalid', __( 'Enter valid Service details.', 'booking' ) );
325 }
326
327 $allowed_keys = array(
328 'draft_id',
329 'source_service_id',
330 'title',
331 'description',
332 'picture_url',
333 'duration_minutes',
334 'buffer_before_minutes',
335 'buffer_after_minutes',
336 'base_cost',
337 'status',
338 );
339 if ( array_diff( array_keys( $raw_service ), $allowed_keys ) ) {
340 return new WP_Error( 'wpbc_setup_wizard_service_unknown_field', __( 'The Service draft contains an unsupported field.', 'booking' ) );
341 }
342
343 $title = isset( $raw_service['title'] ) && is_scalar( $raw_service['title'] )
344 ? wp_html_excerpt( sanitize_text_field( (string) $raw_service['title'] ), 200, '' )
345 : '';
346 if ( $require_complete && '' === trim( $title ) ) {
347 return new WP_Error( 'wpbc_setup_wizard_service_title_required', __( 'Enter a title for every Service.', 'booking' ) );
348 }
349
350 $description = isset( $raw_service['description'] ) && is_scalar( $raw_service['description'] )
351 ? sanitize_textarea_field( (string) $raw_service['description'] )
352 : '';
353 if ( $this->get_text_length( $description ) > self::MAX_DESCRIPTION_LENGTH ) {
354 return new WP_Error( 'wpbc_setup_wizard_service_description_too_long', __( 'Use 2000 characters or fewer for each Service description.', 'booking' ) );
355 }
356
357 $picture_url = isset( $raw_service['picture_url'] ) && is_scalar( $raw_service['picture_url'] ) ? trim( (string) $raw_service['picture_url'] ) : '';
358 if ( $this->get_text_length( $picture_url ) > self::MAX_PICTURE_URL_LENGTH ) {
359 return new WP_Error( 'wpbc_setup_wizard_service_picture_too_long', __( 'Choose a valid Service image.', 'booking' ) );
360 }
361 $sanitized_picture_url = '' === $picture_url ? '' : esc_url_raw( $picture_url );
362 if ( '' !== $picture_url && '' === $sanitized_picture_url ) {
363 return new WP_Error( 'wpbc_setup_wizard_service_picture_invalid', __( 'Choose a valid Service image.', 'booking' ) );
364 }
365
366 $duration_minutes = $this->validate_minutes( isset( $raw_service['duration_minutes'] ) ? $raw_service['duration_minutes'] : 30, 1, __( 'Enter a Service duration between 1 and 1440 minutes.', 'booking' ) );
367 if ( is_wp_error( $duration_minutes ) ) {
368 return $duration_minutes;
369 }
370
371 $buffer_before = $this->validate_minutes( isset( $raw_service['buffer_before_minutes'] ) ? $raw_service['buffer_before_minutes'] : 0, 0, __( 'Enter a buffer between 0 and 1440 minutes.', 'booking' ) );
372 if ( is_wp_error( $buffer_before ) ) {
373 return $buffer_before;
374 }
375
376 $buffer_after = $this->validate_minutes( isset( $raw_service['buffer_after_minutes'] ) ? $raw_service['buffer_after_minutes'] : 0, 0, __( 'Enter a buffer between 0 and 1440 minutes.', 'booking' ) );
377 if ( is_wp_error( $buffer_after ) ) {
378 return $buffer_after;
379 }
380
381 $base_cost = '0.00';
382 if ( $this->is_pricing_available() ) {
383 $raw_base_cost = isset( $raw_service['base_cost'] ) ? $raw_service['base_cost'] : 0;
384 if ( ! is_scalar( $raw_base_cost ) || ! is_numeric( $raw_base_cost ) || 0 > (float) $raw_base_cost || self::MAX_BASE_COST < (float) $raw_base_cost ) {
385 return new WP_Error( 'wpbc_setup_wizard_service_price_invalid', __( 'Enter a Service price between 0 and 1000.', 'booking' ) );
386 }
387 $base_cost = number_format( (float) $raw_base_cost, 2, '.', '' );
388 }
389
390 $draft_id = isset( $raw_service['draft_id'] ) && is_scalar( $raw_service['draft_id'] )
391 ? sanitize_key( (string) $raw_service['draft_id'] )
392 : '';
393 if ( '' === $draft_id ) {
394 $draft_id = 'draft-' . ( $service_index + 1 );
395 }
396
397 return array(
398 'draft_id' => substr( $draft_id, 0, 64 ),
399 'source_service_id' => isset( $raw_service['source_service_id'] ) && is_scalar( $raw_service['source_service_id'] ) ? absint( $raw_service['source_service_id'] ) : 0,
400 'title' => $title,
401 'description' => $description,
402 'picture_url' => $sanitized_picture_url,
403 'duration_minutes' => $duration_minutes,
404 'buffer_before_minutes' => $buffer_before,
405 'buffer_after_minutes' => $buffer_after,
406 'base_cost' => $base_cost,
407 'status' => 'active',
408 );
409 }
410
411 /**
412 * Validate one non-negative minute value against the Service draft bound.
413 *
414 * @param mixed $raw_value Untrusted minute value.
415 * @param int $minimum_value Minimum accepted value.
416 * @param string $error_message Translated error message.
417 *
418 * @return int|WP_Error Normalized minutes or an error.
419 */
420 private function validate_minutes( $raw_value, $minimum_value, $error_message ) {
421 if ( ! is_scalar( $raw_value ) || ! preg_match( '/^\d+$/', (string) $raw_value ) ) {
422 return new WP_Error( 'wpbc_setup_wizard_service_minutes_invalid', $error_message );
423 }
424
425 $minutes = absint( $raw_value );
426 if ( $minimum_value > $minutes || self::MAX_MINUTES < $minutes ) {
427 return new WP_Error( 'wpbc_setup_wizard_service_minutes_invalid', $error_message );
428 }
429
430 return $minutes;
431 }
432
433 /**
434 * Determine whether the active edition exposes canonical Service pricing.
435 *
436 * @return bool True when Service pricing is available.
437 */
438 private function is_pricing_available() {
439 return function_exists( 'wpbc_appointment_services_is_pricing_available' )
440 && wpbc_appointment_services_is_pricing_available();
441 }
442
443 /**
444 * Measure text without requiring the multibyte PHP extension.
445 *
446 * @param string $text Text value.
447 *
448 * @return int Character or byte length.
449 */
450 private function get_text_length( $text ) {
451 return function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text );
452 }
453 }
454