PluginProbe
Booking Calendar / 11.6
Booking Calendar v11.6
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-appointment-services / mutations / class-wpbc-appointment-services-catalog-editor.php

class-wpbc-appointment-services-catalog-editor.php in Booking Calendar 11.6, at includes/page-appointment-services/mutations/class-wpbc-appointment-services-catalog-editor.php

455 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Reviewed inline and bulk mutation service for the Appointment Services catalog.
4 *
5 * @package Booking Calendar
6 * @since 11.6.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) { exit; }
10
11 /**
12 * Apply allow-listed Service changes only after a signed, current-state review.
13 *
14 * Service SQL, edition checks, ownership, and validation remain in the Service
15 * domain. The shared catalog supplies selection mechanics only.
16 */
17 final class WPBC_Appointment_Services_Catalog_Editor {
18
19 /** Maximum number of rows accepted by one catalog editing request. */
20 const MAX_ROWS = 100;
21
22 /** Maximum lifetime of a signed review in seconds. */
23 const REVIEW_LIFETIME = 600;
24
25 /** @var object Service-domain repository. */
26 private $repository;
27
28 /** @var WPBC_Appointment_Services_Catalog_Inline_Fields Domain field provider. */
29 private $inline_fields;
30
31 /**
32 * Construct the editor with an optional repository for runtime tests.
33 *
34 * @param object|null $repository Service repository exposing find() and save().
35 * @param WPBC_Appointment_Services_Catalog_Inline_Fields|null $inline_fields Optional field provider for tests.
36 */
37 public function __construct( $repository = null, $inline_fields = null ) {
38 $this->repository = is_object( $repository ) ? $repository : wpbc_appointment_services_get_data_provider();
39 $this->inline_fields = $inline_fields instanceof WPBC_Appointment_Services_Catalog_Inline_Fields ? $inline_fields : new WPBC_Appointment_Services_Catalog_Inline_Fields();
40 }
41
42 /**
43 * Return authorized row-specific controls for the visible Services.
44 *
45 * The browser receives only executable-free field metadata. Current edition,
46 * ownership, and field restrictions are recalculated again during preview
47 * and apply, so this presentation contract is never treated as authority.
48 *
49 * @param mixed $service_ids Visible Service identifiers in catalog order.
50 *
51 * @return array<string,mixed>|WP_Error Browser-safe schema or validation error.
52 */
53 public function get_inline_schema( $service_ids ) {
54 $services = $this->load_services( $service_ids );
55 if ( is_wp_error( $services ) ) {
56 return $services;
57 }
58
59 $rows = array();
60 foreach ( $services as $service ) {
61 $rows[] = array(
62 'service_id' => absint( $service['service_id'] ),
63 'title' => sanitize_text_field( (string) $service['title'] ),
64 'fields' => $this->get_inline_fields( $service ),
65 );
66 }
67
68 return array(
69 'maximum_rows' => self::MAX_ROWS,
70 'rows' => $rows,
71 );
72 }
73
74 /**
75 * Return the safe bulk-field intersection for an authorized selection.
76 *
77 * Booking Form choices are owner-scoped. They are removed when a selection
78 * spans owners even if a future permission allows those rows to be selected
79 * together. All other returned fields are domain-safe for every loaded row.
80 *
81 * @param mixed $service_ids Selected Service identifiers.
82 *
83 * @return array<string,mixed>|WP_Error Browser-safe bulk contract or error.
84 */
85 public function get_bulk_contract( $service_ids ) {
86 $services = $this->load_services( $service_ids );
87 if ( is_wp_error( $services ) ) {
88 return $services;
89 }
90
91 return array(
92 'fields' => $this->get_bulk_fields_for_services( $services ),
93 'message' => __( 'Only fields that are safe for every selected Service are available.', 'booking' ),
94 );
95 }
96
97 /**
98 * Return browser-safe definitions for fields shared by selected Services.
99 *
100 * @return array<int,array<string,mixed>> Allow-listed bulk field definitions.
101 */
102 public function get_bulk_fields() {
103 return $this->inline_fields->get_bulk_fields( array() );
104 }
105
106 /**
107 * Build a non-mutating signed review for inline or bulk Service changes.
108 *
109 * @param string $mode Either inline or bulk.
110 * @param mixed $ids Selected Service identifiers.
111 * @param mixed $changes Inline changes keyed by ID, or shared bulk fields.
112 *
113 * @return array<string,mixed>|WP_Error Review rows and signed token, or error.
114 */
115 public function preview( $mode, $ids, $changes ) {
116 $mode = in_array( $mode, array( 'inline', 'bulk' ), true ) ? $mode : '';
117 $ids = $this->normalize_ids( $ids );
118 if ( '' === $mode || is_wp_error( $ids ) ) {
119 return new WP_Error( 'wpbc_service_invalid_selection', __( 'Select between 1 and 100 Services.', 'booking' ) );
120 }
121 if ( ! is_object( $this->repository ) || ! method_exists( $this->repository, 'find' ) || ! method_exists( $this->repository, 'save' ) ) {
122 return wpbc_appointment_services_storage_error();
123 }
124
125 $current_services = $this->load_services( $ids );
126 if ( is_wp_error( $current_services ) ) {
127 return $current_services;
128 }
129 $services_by_id = array();
130 foreach ( $current_services as $current_service ) {
131 $services_by_id[ absint( $current_service['service_id'] ) ] = $current_service;
132 }
133 $bulk_allowed = 'bulk' === $mode ? wp_list_pluck( $this->get_bulk_fields_for_services( $current_services ), 'key' ) : null;
134
135 $changes = is_array( $changes ) ? $changes : array();
136 $plan = array(
137 'version' => 1,
138 'mode' => $mode,
139 'site_id' => get_current_blog_id(),
140 'user_id' => get_current_user_id(),
141 'expires_at' => time() + self::REVIEW_LIFETIME,
142 'services' => array(),
143 );
144 $rows = array();
145 foreach ( $ids as $service_id ) {
146 $current = isset( $services_by_id[ $service_id ] ) ? $services_by_id[ $service_id ] : null;
147 if ( ! is_array( $current ) ) { return new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) ); }
148 $requested = 'bulk' === $mode ? $changes : ( isset( $changes[ $service_id ] ) && is_array( $changes[ $service_id ] ) ? $changes[ $service_id ] : array() );
149 $validated = $this->validate_changes( $requested, 'inline' === $mode, $current, $bulk_allowed );
150 if ( is_wp_error( $validated ) ) { return $validated; }
151 $row_changes = $this->build_row_changes( $current, $validated );
152 if ( empty( $row_changes ) ) { continue; }
153 $plan['services'][] = array(
154 'id' => $service_id,
155 'changes' => $validated,
156 'snapshot' => $this->snapshot_hash( $current ),
157 );
158 $rows[] = array( 'id' => $service_id, 'title' => sanitize_text_field( $current['title'] ), 'changes' => $row_changes );
159 }
160 if ( empty( $rows ) ) {
161 return new WP_Error( 'wpbc_service_no_changes', __( 'No Service changes require review.', 'booking' ) );
162 }
163
164 $review_rows = array();
165 foreach ( $rows as $row ) {
166 $review_rows[] = array(
167 'id' => absint( $row['id'] ),
168 'title' => (string) $row['title'],
169 'fields' => array_values( $row['changes'] ),
170 'notes' => array(),
171 );
172 }
173
174 return array( 'rows' => $rows, 'review' => array( 'rows' => $review_rows ), 'token' => $this->sign_plan( $plan ), 'plan' => $plan );
175 }
176
177 /**
178 * Apply a previously reviewed plan after current-state revalidation.
179 *
180 * Completed saves are compensated in reverse order if a later save fails.
181 *
182 * @param mixed $plan Review plan returned by preview().
183 * @param string $token Signed review token.
184 *
185 * @return array<string,mixed>|WP_Error Changed IDs or mutation error.
186 */
187 public function apply( $plan, $token ) {
188 $plan = is_array( $plan ) ? $plan : array();
189 $token = is_scalar( $token ) ? (string) $token : '';
190 $mode = isset( $plan['mode'] ) && in_array( $plan['mode'], array( 'inline', 'bulk' ), true ) ? $plan['mode'] : '';
191 if ( ! $this->is_valid_plan_envelope( $plan, $token, $mode ) ) {
192 return new WP_Error( 'wpbc_service_invalid_review', __( 'This Service review is invalid or has expired.', 'booking' ) );
193 }
194 if ( ! is_object( $this->repository ) || ! method_exists( $this->repository, 'find' ) || ! method_exists( $this->repository, 'save' ) ) {
195 return wpbc_appointment_services_storage_error();
196 }
197
198 $before = array();
199 $validated_plans = array();
200 foreach ( $plan['services'] as $service_plan ) {
201 $service_id = isset( $service_plan['id'] ) ? absint( $service_plan['id'] ) : 0;
202 $current = $service_id ? $this->repository->find( $service_id ) : new WP_Error( 'invalid_service', __( 'The Service selection is invalid.', 'booking' ) );
203 if ( is_wp_error( $current ) ) { return $current; }
204 if ( empty( $service_plan['snapshot'] ) || ! hash_equals( (string) $service_plan['snapshot'], $this->snapshot_hash( $current ) ) ) {
205 return new WP_Error( 'wpbc_service_stale_review', __( 'A selected Service changed after review. Review the changes again.', 'booking' ) );
206 }
207 $before[ $service_id ] = $current;
208 }
209 $bulk_allowed = 'bulk' === $mode ? wp_list_pluck( $this->get_bulk_fields_for_services( array_values( $before ) ), 'key' ) : null;
210 foreach ( $plan['services'] as $service_plan ) {
211 $service_id = absint( $service_plan['id'] );
212 $validated_changes = $this->validate_changes(
213 isset( $service_plan['changes'] ) && is_array( $service_plan['changes'] ) ? $service_plan['changes'] : array(),
214 'inline' === $mode,
215 $before[ $service_id ],
216 $bulk_allowed
217 );
218 if ( is_wp_error( $validated_changes ) || empty( $validated_changes ) ) {
219 return is_wp_error( $validated_changes ) ? $validated_changes : new WP_Error( 'wpbc_service_no_changes', __( 'No valid Service changes remain to apply.', 'booking' ) );
220 }
221 $validated_plans[ $service_id ] = $validated_changes;
222 }
223
224 $changed_ids = array();
225 foreach ( $plan['services'] as $service_plan ) {
226 $service_id = absint( $service_plan['id'] );
227 $payload = array_merge( $before[ $service_id ], $validated_plans[ $service_id ], array( 'service_id' => $service_id ) );
228 $result = $this->repository->save( $payload );
229 if ( is_wp_error( $result ) ) {
230 // A repository may fail after updating its Service row but before its Provider assignments.
231 $this->repository->save( $before[ $service_id ] );
232 foreach ( array_reverse( $changed_ids ) as $changed_id ) {
233 $this->repository->save( $before[ $changed_id ] );
234 }
235 return new WP_Error( 'wpbc_service_apply_failed', __( 'The Service changes could not be completed. Previous values were restored where possible.', 'booking' ) );
236 }
237 $changed_ids[] = $service_id;
238 }
239
240 do_action( 'wpbc_appointment_services_catalog_updated', $changed_ids, $mode );
241
242 return array( 'changed_ids' => $changed_ids );
243 }
244
245 /**
246 * Validate an allow-listed set of Service fields.
247 *
248 * @param array<string,mixed> $changes Requested changes.
249 * @param bool $inline_mode Whether the request is row-specific inline editing.
250 * @param array<string,mixed> $service Current authorized Service row.
251 * @param array<int,string>|null $allowed_override Selection-specific bulk allow-list.
252 *
253 * @return array<string,mixed>|WP_Error Valid changes or validation error.
254 */
255 private function validate_changes( $changes, $inline_mode, $service, $allowed_override = null ) {
256 $field_definitions = $inline_mode ? $this->get_inline_fields( $service ) : $this->get_bulk_fields();
257 $allowed = is_array( $allowed_override ) ? $allowed_override : wp_list_pluck( $field_definitions, 'key' );
258 $changes = is_array( $changes ) ? $changes : array();
259 if ( array_diff( array_keys( $changes ), $allowed ) ) {
260 return new WP_Error( 'wpbc_service_unsupported_field', __( 'One or more Service fields are no longer available for this operation.', 'booking' ) );
261 }
262 $validated = array();
263 foreach ( $changes as $field_id => $raw_value ) {
264 if ( ! is_scalar( $raw_value ) ) {
265 return new WP_Error( 'wpbc_service_invalid_field', __( 'A Service field contains an invalid value.', 'booking' ) );
266 }
267 switch ( $field_id ) {
268 case 'title':
269 $validated[ $field_id ] = wp_html_excerpt( sanitize_text_field( $raw_value ), 200, '' );
270 if ( '' === $validated[ $field_id ] ) { return new WP_Error( 'wpbc_service_title_required', __( 'Every Service must have a title.', 'booking' ) ); }
271 break;
272 case 'description':
273 $description = sanitize_textarea_field( $raw_value );
274 $description_length = function_exists( 'mb_strlen' ) ? mb_strlen( $description ) : strlen( $description );
275 if ( 2000 < $description_length ) { return new WP_Error( 'wpbc_service_description_too_long', __( 'The Service description is too long.', 'booking' ) ); }
276 $validated[ $field_id ] = $description;
277 break;
278 case 'duration_minutes':
279 if ( ! $this->is_integer_value( $raw_value ) || 1 > (int) $raw_value || 1440 < (int) $raw_value ) { return new WP_Error( 'wpbc_service_invalid_duration', __( 'Enter a Service duration between 1 and 1440 minutes.', 'booking' ) ); }
280 $validated[ $field_id ] = absint( $raw_value );
281 break;
282 case 'buffer_before_minutes':
283 case 'buffer_after_minutes':
284 if ( ! $this->is_integer_value( $raw_value ) || 0 > (int) $raw_value || 1440 < (int) $raw_value ) { return new WP_Error( 'wpbc_service_invalid_buffer', __( 'Enter a Service buffer between 0 and 1440 minutes.', 'booking' ) ); }
285 $validated[ $field_id ] = absint( $raw_value );
286 break;
287 case 'base_cost':
288 if ( ! wpbc_appointment_services_is_pricing_available() || ! is_numeric( $raw_value ) || 0 > (float) $raw_value || 1000 < (float) $raw_value ) { return new WP_Error( 'wpbc_service_invalid_price', __( 'Enter a Service price between 0 and 1000.', 'booking' ) ); }
289 $validated[ $field_id ] = number_format( (float) $raw_value, 2, '.', '' );
290 break;
291 case 'booking_form_id':
292 $form_id = absint( $raw_value );
293 if ( ! array_key_exists( $form_id, wpbc_appointment_services_get_form_options() ) ) { return new WP_Error( 'wpbc_service_invalid_form', __( 'The selected Booking Form is unavailable.', 'booking' ) ); }
294 $validated[ $field_id ] = $form_id;
295 break;
296 case 'status':
297 $status = sanitize_key( $raw_value );
298 if ( ! in_array( $status, array( 'active', 'inactive', 'archived' ), true ) ) { return new WP_Error( 'wpbc_service_invalid_status', __( 'The selected Service status is invalid.', 'booking' ) ); }
299 $validated[ $field_id ] = $status;
300 break;
301 }
302 }
303
304 return $validated;
305 }
306
307 /**
308 * Determine whether a submitted scalar represents a whole integer.
309 *
310 * Number controls can be manipulated outside the browser, so this prevents
311 * decimal values from being silently truncated during server validation.
312 *
313 * @param mixed $raw_value Submitted field value.
314 * @return bool True when the value contains only an optional minus sign and digits.
315 */
316 private function is_integer_value( $raw_value ) {
317 return is_scalar( $raw_value ) && 1 === preg_match( '/^-?\d+$/', (string) $raw_value );
318 }
319
320 /**
321 * Build the current row-specific inline field definitions for one Service.
322 *
323 * @param array<string,mixed> $service Current authorized Service row.
324 *
325 * @return array<int,array<string,mixed>> Executable-free field definitions.
326 */
327 private function get_inline_fields( $service ) {
328 return $this->inline_fields->get_inline_fields( $service );
329 }
330
331 /**
332 * Calculate fields shared safely by a loaded Service collection.
333 *
334 * @param array<int,array<string,mixed>> $services Authorized Service rows.
335 *
336 * @return array<int,array<string,mixed>> Selection-specific field definitions.
337 */
338 private function get_bulk_fields_for_services( $services ) {
339 return $this->inline_fields->get_bulk_fields( $services );
340 }
341
342 /**
343 * Normalize one bounded identifier collection without accepting partial input.
344 *
345 * @param mixed $service_ids Requested Service identifiers.
346 *
347 * @return array<int,int>|WP_Error Unique positive identifiers or error.
348 */
349 private function normalize_ids( $service_ids ) {
350 $raw_ids = is_array( $service_ids ) ? $service_ids : array();
351 $ids = array_values( array_unique( array_filter( array_map( 'absint', $raw_ids ) ) ) );
352 if ( empty( $ids ) || self::MAX_ROWS < count( $ids ) || count( $ids ) !== count( $raw_ids ) ) {
353 return new WP_Error( 'wpbc_service_invalid_selection', __( 'Select between 1 and 100 Services.', 'booking' ) );
354 }
355
356 return $ids;
357 }
358
359 /**
360 * Load an authorized bounded Service collection in request order.
361 *
362 * @param mixed $service_ids Requested Service identifiers.
363 *
364 * @return array<int,array<string,mixed>>|WP_Error Service rows or safe error.
365 */
366 private function load_services( $service_ids ) {
367 $service_ids = $this->normalize_ids( $service_ids );
368 if ( is_wp_error( $service_ids ) ) {
369 return $service_ids;
370 }
371 if ( ! is_object( $this->repository ) || ! method_exists( $this->repository, 'find' ) ) {
372 return wpbc_appointment_services_storage_error();
373 }
374
375 $services = array();
376 foreach ( $service_ids as $service_id ) {
377 $service = $this->repository->find( $service_id );
378 if ( is_wp_error( $service ) ) {
379 return $service;
380 }
381 $services[] = $service;
382 }
383
384 return $services;
385 }
386
387 /**
388 * Build human-readable field differences for one review card.
389 *
390 * @param array<string,mixed> $current Current Service row.
391 * @param array<string,mixed> $changes Validated changes.
392 *
393 * @return array<int,array<string,string>> Changed field records.
394 */
395 private function build_row_changes( $current, $changes ) {
396 $labels = array();
397 foreach ( array_merge( $this->inline_fields->get_inline_fields( $current ), $this->inline_fields->get_bulk_fields( array( $current ) ) ) as $field ) {
398 $labels[ $field['key'] ] = $field['label'];
399 }
400 $rows = array();
401 foreach ( $changes as $field_id => $after ) {
402 $before = isset( $current[ $field_id ] ) ? $current[ $field_id ] : '';
403 if ( (string) $before === (string) $after || ( 'base_cost' === $field_id && (float) $before === (float) $after ) ) { continue; }
404 $rows[] = array( 'key' => sanitize_key( $field_id ), 'label' => isset( $labels[ $field_id ] ) ? $labels[ $field_id ] : $field_id, 'before' => (string) $before, 'after' => (string) $after );
405 }
406
407 return $rows;
408 }
409
410 /**
411 * Hash values that must remain current between review and apply.
412 *
413 * @param array<string,mixed> $service Current repository row.
414 *
415 * @return string Current-state hash.
416 */
417 private function snapshot_hash( $service ) {
418 return hash( 'sha256', wp_json_encode( $service ) );
419 }
420
421 /**
422 * Sign a review plan with the current WordPress nonce salt.
423 *
424 * @param array<string,mixed> $plan Review plan.
425 *
426 * @return string Signed plan token.
427 */
428 private function sign_plan( $plan ) {
429 return hash_hmac( 'sha256', wp_json_encode( $plan ), wp_salt( 'nonce' ) );
430 }
431
432 /**
433 * Validate one signed review envelope against the current user and site.
434 *
435 * @param array $plan Submitted review plan.
436 * @param string $token Submitted review signature.
437 * @param string $mode Normalized editing mode.
438 * @return bool True when the review is authentic, current, and bounded.
439 */
440 private function is_valid_plan_envelope( $plan, $token, $mode ) {
441 return '' !== $mode
442 && '' !== $token
443 && isset( $plan['version'], $plan['mode'], $plan['site_id'], $plan['user_id'], $plan['expires_at'], $plan['services'] )
444 && 1 === absint( $plan['version'] )
445 && $mode === $plan['mode']
446 && get_current_blog_id() === absint( $plan['site_id'] )
447 && get_current_user_id() === absint( $plan['user_id'] )
448 && time() <= absint( $plan['expires_at'] )
449 && is_array( $plan['services'] )
450 && ! empty( $plan['services'] )
451 && self::MAX_ROWS >= count( $plan['services'] )
452 && hash_equals( $this->sign_plan( $plan ), $token );
453 }
454 }
455