booking
/
includes
/
page-appointment-services
/
mutations
/
class-wpbc-appointment-services-catalog-deleter.php
class-wpbc-appointment-services-catalog-deleter.php in Booking Calendar 11.7, at includes/page-appointment-services/mutations/class-wpbc-appointment-services-catalog-deleter.php
| 1 | <?php |
| 2 | /** |
| 3 | * Independent reviewed deletion service for the Appointment Services catalog. |
| 4 | * |
| 5 | * @package Booking Calendar |
| 6 | * @since 11.6.0 |
| 7 | */ |
| 8 | |
| 9 | if ( ! defined( 'ABSPATH' ) ) { |
| 10 | exit; |
| 11 | } |
| 12 | |
| 13 | /** |
| 14 | * Build and apply site-bound Service deletion plans without the legacy editor. |
| 15 | */ |
| 16 | final class WPBC_Appointment_Services_Catalog_Deleter { |
| 17 | |
| 18 | /** Maximum number of Services accepted by one reviewed deletion. */ |
| 19 | const MAX_SELECTION = 100; |
| 20 | |
| 21 | /** Maximum signed deletion-review lifetime in seconds. */ |
| 22 | const REVIEW_LIFETIME = 600; |
| 23 | |
| 24 | /** @var object Service data provider. */ |
| 25 | private $repository; |
| 26 | |
| 27 | /** |
| 28 | * Set the Service repository used by deletion preview and apply. |
| 29 | * |
| 30 | * @param object|null $repository Optional provider for tests or integrations. |
| 31 | */ |
| 32 | public function __construct( $repository = null ) { |
| 33 | $this->repository = $repository ? $repository : wpbc_appointment_services_get_data_provider(); |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * Build a signed, non-mutating permanent-deletion review. |
| 38 | * |
| 39 | * @param mixed $service_ids Requested Service IDs. |
| 40 | * |
| 41 | * @return array<string,mixed>|WP_Error Review DTO, signed plan, and policy state. |
| 42 | */ |
| 43 | public function preview( $service_ids ) { |
| 44 | $authorization = $this->get_authorization_error(); |
| 45 | if ( is_wp_error( $authorization ) ) { |
| 46 | return $authorization; |
| 47 | } |
| 48 | |
| 49 | $before_images = $this->load_selected_before_images( $service_ids ); |
| 50 | if ( is_wp_error( $before_images ) ) { |
| 51 | return $before_images; |
| 52 | } |
| 53 | |
| 54 | $review_items = array(); |
| 55 | $plan_items = array(); |
| 56 | $can_apply = true; |
| 57 | $impacts = $this->repository->get_deletion_impacts( array_keys( $before_images ), $before_images ); |
| 58 | if ( is_wp_error( $impacts ) ) { |
| 59 | return $impacts; |
| 60 | } |
| 61 | foreach ( $before_images as $service_id => $before_image ) { |
| 62 | $impact = $impacts[ $service_id ]; |
| 63 | |
| 64 | $notes = $this->get_impact_notes( $impact ); |
| 65 | $item_can_apply = ! empty( $impact['is_complete'] ) |
| 66 | && empty( $impact['appointment_count'] ) |
| 67 | && empty( $impact['post_reference_count'] ); |
| 68 | $can_apply = $can_apply && $item_can_apply; |
| 69 | $review_items[] = array( |
| 70 | 'id' => $service_id, |
| 71 | 'title' => sanitize_text_field( (string) $before_image['service']['title'] ), |
| 72 | 'notes' => $notes, |
| 73 | 'actions' => $this->get_impact_actions( $service_id, $impact ), |
| 74 | 'can_apply' => $item_can_apply, |
| 75 | ); |
| 76 | $plan_items[] = array( |
| 77 | 'id' => $service_id, |
| 78 | 'snapshot' => $this->deletion_snapshot_hash( $before_image, $impact ), |
| 79 | ); |
| 80 | } |
| 81 | |
| 82 | $selection_count = count( $review_items ); |
| 83 | $i18n = $this->get_delete_review_i18n( $selection_count ); |
| 84 | $warning = $can_apply |
| 85 | ? _n( 'This permanently removes the selected Service and its Provider assignments. This action cannot be undone.', 'This permanently removes the selected Services and their Provider assignments. This action cannot be undone.', $selection_count, 'booking' ) |
| 86 | : __( 'Deletion is blocked because one or more selected Services are referenced by Appointments or saved page configuration. Archive the Service or remove those references first, then review deletion again.', 'booking' ); |
| 87 | $plan = array( |
| 88 | 'version' => 1, |
| 89 | 'mode' => 'delete', |
| 90 | 'site_id' => get_current_blog_id(), |
| 91 | 'user_id' => get_current_user_id(), |
| 92 | 'expires_at' => time() + self::REVIEW_LIFETIME, |
| 93 | 'services' => $plan_items, |
| 94 | ); |
| 95 | |
| 96 | return array( |
| 97 | 'delete_review' => array( |
| 98 | 'items' => $review_items, |
| 99 | 'selection_count' => $selection_count, |
| 100 | 'can_apply' => $can_apply, |
| 101 | 'warning' => $warning, |
| 102 | 'i18n' => $i18n, |
| 103 | ), |
| 104 | 'plan' => $plan, |
| 105 | 'token' => $this->sign_plan( $plan ), |
| 106 | 'can_apply' => $can_apply, |
| 107 | 'warning' => $warning, |
| 108 | ); |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Apply a signed deletion after complete apply-time authorization and impact revalidation. |
| 113 | * |
| 114 | * @param mixed $plan Signed plan returned by preview(). |
| 115 | * @param string $token Submitted HMAC token. |
| 116 | * |
| 117 | * @return array<string,mixed>|WP_Error Deleted IDs or a safe error. |
| 118 | */ |
| 119 | public function apply( $plan, $token ) { |
| 120 | $authorization = $this->get_authorization_error(); |
| 121 | if ( is_wp_error( $authorization ) ) { |
| 122 | return $authorization; |
| 123 | } |
| 124 | |
| 125 | $plan = is_array( $plan ) ? $plan : array(); |
| 126 | $token = is_scalar( $token ) ? (string) $token : ''; |
| 127 | if ( ! $this->is_valid_plan_envelope( $plan, $token ) ) { |
| 128 | return new WP_Error( 'wpbc_service_invalid_delete_review', __( 'This Service deletion review is invalid or has expired. Review it again.', 'booking' ) ); |
| 129 | } |
| 130 | |
| 131 | $service_ids = wp_list_pluck( $plan['services'], 'id' ); |
| 132 | $before_images = $this->load_selected_before_images( $service_ids ); |
| 133 | if ( is_wp_error( $before_images ) ) { |
| 134 | return $before_images; |
| 135 | } |
| 136 | $impacts = $this->repository->get_deletion_impacts( $service_ids, $before_images ); |
| 137 | if ( is_wp_error( $impacts ) ) { |
| 138 | return $impacts; |
| 139 | } |
| 140 | |
| 141 | foreach ( $plan['services'] as $service_plan ) { |
| 142 | $service_id = absint( $service_plan['id'] ); |
| 143 | $impact = $impacts[ $service_id ]; |
| 144 | if ( empty( $impact['is_complete'] ) ) { |
| 145 | return new WP_Error( 'wpbc_service_delete_audit_incomplete', __( 'This Service cannot be deleted until all references can be audited safely.', 'booking' ) ); |
| 146 | } |
| 147 | if ( ! empty( $impact['appointment_count'] ) || ! empty( $impact['post_reference_count'] ) ) { |
| 148 | return new WP_Error( 'wpbc_service_delete_referenced', __( 'This Service is referenced and cannot be permanently deleted.', 'booking' ) ); |
| 149 | } |
| 150 | if ( empty( $service_plan['snapshot'] ) || ! hash_equals( (string) $service_plan['snapshot'], $this->deletion_snapshot_hash( $before_images[ $service_id ], $impact ) ) ) { |
| 151 | return new WP_Error( 'wpbc_service_stale_delete_review', __( 'A selected Service or one of its references changed after review. Review the deletion again.', 'booking' ) ); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | $transaction_started = $this->repository->begin_transaction(); |
| 156 | $deleted_ids = array(); |
| 157 | foreach ( $plan['services'] as $service_plan ) { |
| 158 | $service_id = absint( $service_plan['id'] ); |
| 159 | $result = $this->repository->compare_and_delete_service( $before_images[ $service_id ] ); |
| 160 | if ( is_wp_error( $result ) ) { |
| 161 | if ( $transaction_started ) { |
| 162 | $this->repository->rollback_transaction(); |
| 163 | } |
| 164 | $compensation = $this->compensate_deleted_services( $deleted_ids, $before_images ); |
| 165 | return is_wp_error( $compensation ) ? $compensation : $result; |
| 166 | } |
| 167 | $deleted_ids[] = $service_id; |
| 168 | } |
| 169 | |
| 170 | if ( $transaction_started && ! $this->repository->commit_transaction() ) { |
| 171 | $this->repository->rollback_transaction(); |
| 172 | $compensation = $this->compensate_deleted_services( $deleted_ids, $before_images ); |
| 173 | return is_wp_error( $compensation ) ? $compensation : new WP_Error( 'wpbc_service_delete_failed', __( 'The Service deletion could not be committed.', 'booking' ) ); |
| 174 | } |
| 175 | |
| 176 | do_action( 'wpbc_appointment_services_deleted', $deleted_ids, $before_images, get_current_user_id(), get_current_blog_id() ); |
| 177 | |
| 178 | return array( |
| 179 | 'deleted_ids' => array_values( array_map( 'absint', $deleted_ids ) ), |
| 180 | 'deleted_count' => count( $deleted_ids ), |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * Return localized labels for the domain-owned delete-review template. |
| 186 | * |
| 187 | * @param int $selection_count Number of Services in the reviewed batch. |
| 188 | * |
| 189 | * @return array<string,string> Localized labels. |
| 190 | */ |
| 191 | private function get_delete_review_i18n( $selection_count ) { |
| 192 | $selection_count = max( 1, absint( $selection_count ) ); |
| 193 | |
| 194 | return array( |
| 195 | 'title' => _n( 'Delete Service', 'Delete Services', $selection_count, 'booking' ), |
| 196 | 'selection_label' => sprintf( |
| 197 | /* translators: %s: Number of selected Services. */ |
| 198 | _n( '%s Service selected', '%s Services selected', $selection_count, 'booking' ), |
| 199 | number_format_i18n( $selection_count ) |
| 200 | ), |
| 201 | 'description' => _n( 'Review this permanent action and its reference impact before deleting the selected Service.', 'Review this permanent action and its reference impact before deleting the selected Services.', $selection_count, 'booking' ), |
| 202 | 'pending_message' => _n( 'No Service will change until you choose Delete Service.', 'No Service will change until you choose Delete Services.', $selection_count, 'booking' ), |
| 203 | 'items_heading' => _n( 'Service to be permanently deleted', 'Services to be permanently deleted', $selection_count, 'booking' ), |
| 204 | 'acknowledgement' => _n( 'I understand that this Service will be permanently deleted.', 'I understand that these Services will be permanently deleted.', $selection_count, 'booking' ), |
| 205 | 'delete_button' => sprintf( |
| 206 | /* translators: %s: Number of Services to delete. */ |
| 207 | _n( 'Delete %s Service', 'Delete %s Services', $selection_count, 'booking' ), |
| 208 | number_format_i18n( $selection_count ) |
| 209 | ), |
| 210 | 'id_label' => __( 'Service ID', 'booking' ), |
| 211 | 'actions_heading' => __( 'Open blocking references', 'booking' ), |
| 212 | ); |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Build capability-aware direct links for one Service impact audit. |
| 217 | * |
| 218 | * @param int $service_id Service ID. |
| 219 | * @param array<string,mixed> $impact Current reference audit. |
| 220 | * |
| 221 | * @return array<int,array<string,string>> Authorized blocker actions. |
| 222 | */ |
| 223 | private function get_impact_actions( $service_id, $impact ) { |
| 224 | $actions = array(); |
| 225 | $appointment_count = isset( $impact['appointment_count'] ) ? absint( $impact['appointment_count'] ) : 0; |
| 226 | |
| 227 | if ( $appointment_count && $this->current_user_can_view_bookings() && function_exists( 'wpbc_get_bookings_url' ) ) { |
| 228 | $actions[] = array( |
| 229 | 'label' => sprintf( |
| 230 | /* translators: %s: Number of blocking Appointments. */ |
| 231 | _n( 'View %s blocking Appointment', 'View %s blocking Appointments', $appointment_count, 'booking' ), |
| 232 | number_format_i18n( $appointment_count ) |
| 233 | ), |
| 234 | 'url' => esc_url_raw( |
| 235 | add_query_arg( |
| 236 | array( |
| 237 | 'tab' => 'vm_booking_listing', |
| 238 | 'wh_appointment_service' => absint( $service_id ), |
| 239 | 'overwrite' => 1, |
| 240 | ), |
| 241 | wpbc_get_bookings_url( true, false ) |
| 242 | ) |
| 243 | ), |
| 244 | 'description' => __( 'Review the Appointment records that retain this Service snapshot. Archive the Service unless that history can be removed safely.', 'booking' ), |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | foreach ( isset( $impact['post_references'] ) && is_array( $impact['post_references'] ) ? $impact['post_references'] : array() as $post_reference ) { |
| 249 | $post_id = isset( $post_reference['id'] ) ? absint( $post_reference['id'] ) : 0; |
| 250 | if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { |
| 251 | continue; |
| 252 | } |
| 253 | $edit_url = get_edit_post_link( $post_id, 'raw' ); |
| 254 | if ( ! $edit_url ) { |
| 255 | continue; |
| 256 | } |
| 257 | $post_title = isset( $post_reference['title'] ) ? sanitize_text_field( (string) $post_reference['title'] ) : ''; |
| 258 | if ( '' === $post_title ) { |
| 259 | $post_title = sprintf( __( 'Untitled content #%s', 'booking' ), number_format_i18n( $post_id ) ); |
| 260 | } |
| 261 | $actions[] = array( |
| 262 | 'label' => sprintf( __( 'Edit saved content: %s', 'booking' ), $post_title ), |
| 263 | 'url' => esc_url_raw( $edit_url ), |
| 264 | 'description' => __( 'Remove or change the Appointment Service restriction in this saved content.', 'booking' ), |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | return $actions; |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Check the configured Booking Listing role using hierarchical capabilities. |
| 273 | * |
| 274 | * @return bool True when the current user can open the Bookings listing. |
| 275 | */ |
| 276 | private function current_user_can_view_bookings() { |
| 277 | $minimum_role = sanitize_key( (string) get_bk_option( 'booking_user_role_booking' ) ); |
| 278 | if ( '' === $minimum_role ) { |
| 279 | return false; |
| 280 | } |
| 281 | if ( function_exists( 'wpbc_is_current_user_have_this_role' ) ) { |
| 282 | return wpbc_is_current_user_have_this_role( $minimum_role ); |
| 283 | } |
| 284 | |
| 285 | $capabilities = array( |
| 286 | 'administrator' => 'activate_plugins', |
| 287 | 'editor' => 'publish_pages', |
| 288 | 'author' => 'publish_posts', |
| 289 | 'contributor' => 'edit_posts', |
| 290 | 'subscriber' => 'read', |
| 291 | ); |
| 292 | |
| 293 | return isset( $capabilities[ $minimum_role ] ) && current_user_can( $capabilities[ $minimum_role ] ); |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Convert one reference audit into authorized review notes. |
| 298 | * |
| 299 | * @param array<string,mixed> $impact Reference audit. |
| 300 | * |
| 301 | * @return array<int,string> Plain localized notes. |
| 302 | */ |
| 303 | private function get_impact_notes( $impact ) { |
| 304 | $notes = array(); |
| 305 | $assignment_count = absint( $impact['assignment_count'] ); |
| 306 | $appointment_count = absint( $impact['appointment_count'] ); |
| 307 | $post_reference_count = absint( $impact['post_reference_count'] ); |
| 308 | |
| 309 | if ( $assignment_count ) { |
| 310 | $notes[] = sprintf( |
| 311 | /* translators: %s: Number of Provider assignments. */ |
| 312 | _n( '%s Provider assignment will also be removed.', '%s Provider assignments will also be removed.', $assignment_count, 'booking' ), |
| 313 | number_format_i18n( $assignment_count ) |
| 314 | ); |
| 315 | } else { |
| 316 | $notes[] = __( 'No Provider assignments.', 'booking' ); |
| 317 | } |
| 318 | if ( $appointment_count ) { |
| 319 | $notes[] = sprintf( |
| 320 | /* translators: %s: Number of Appointment snapshots. */ |
| 321 | _n( '%s Appointment snapshot blocks deletion.', '%s Appointment snapshots block deletion.', $appointment_count, 'booking' ), |
| 322 | number_format_i18n( $appointment_count ) |
| 323 | ); |
| 324 | } |
| 325 | if ( $post_reference_count ) { |
| 326 | $notes[] = sprintf( |
| 327 | /* translators: %s: Number of saved page references. */ |
| 328 | _n( '%s saved page configuration blocks deletion.', '%s saved page configurations block deletion.', $post_reference_count, 'booking' ), |
| 329 | number_format_i18n( $post_reference_count ) |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | return $notes; |
| 334 | } |
| 335 | |
| 336 | /** |
| 337 | * Load authorized deletion before-images for a bounded selection. |
| 338 | * |
| 339 | * @param mixed $service_ids Requested IDs. |
| 340 | * |
| 341 | * @return array<int,array<string,mixed>>|WP_Error Before-images keyed by Service ID. |
| 342 | */ |
| 343 | private function load_selected_before_images( $service_ids ) { |
| 344 | $service_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $service_ids ) ) ) ); |
| 345 | if ( empty( $service_ids ) || self::MAX_SELECTION < count( $service_ids ) ) { |
| 346 | return new WP_Error( 'wpbc_service_invalid_delete_selection', __( 'Select between 1 and 100 Services.', 'booking' ) ); |
| 347 | } |
| 348 | |
| 349 | $before_images = array(); |
| 350 | foreach ( $service_ids as $service_id ) { |
| 351 | $before_image = $this->repository->get_deletion_before_image( $service_id ); |
| 352 | if ( is_wp_error( $before_image ) ) { |
| 353 | return $before_image; |
| 354 | } |
| 355 | $before_images[ $service_id ] = $before_image; |
| 356 | } |
| 357 | |
| 358 | return $before_images; |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * Return an authorization or storage-contract error when deletion is unavailable. |
| 363 | * |
| 364 | * @return true|WP_Error True when available, otherwise an error. |
| 365 | */ |
| 366 | private function get_authorization_error() { |
| 367 | if ( ! current_user_can( wpbc_appointment_services_get_manage_capability() ) ) { |
| 368 | return new WP_Error( 'wpbc_service_delete_forbidden', __( 'You are not allowed to delete Services.', 'booking' ) ); |
| 369 | } |
| 370 | $required_methods = array( 'get_deletion_before_image', 'get_deletion_impacts', 'begin_transaction', 'commit_transaction', 'rollback_transaction', 'compare_and_delete_service', 'restore_deleted_service' ); |
| 371 | if ( ! is_object( $this->repository ) ) { |
| 372 | return wpbc_appointment_services_storage_error(); |
| 373 | } |
| 374 | foreach ( $required_methods as $required_method ) { |
| 375 | if ( ! method_exists( $this->repository, $required_method ) ) { |
| 376 | return new WP_Error( 'wpbc_service_delete_unsupported_storage', __( 'Permanent deletion is unavailable for the configured Service storage provider.', 'booking' ) ); |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | return true; |
| 381 | } |
| 382 | |
| 383 | /** |
| 384 | * Hash canonical storage and reference impact for stale-review detection. |
| 385 | * |
| 386 | * @param array<string,mixed> $before_image Canonical Service and assignment rows. |
| 387 | * @param array<string,mixed> $impact Complete reference audit. |
| 388 | * |
| 389 | * @return string SHA-256 snapshot hash. |
| 390 | */ |
| 391 | private function deletion_snapshot_hash( $before_image, $impact ) { |
| 392 | return hash( 'sha256', wp_json_encode( array( 'before' => $before_image, 'impact' => $impact ) ) ); |
| 393 | } |
| 394 | |
| 395 | /** |
| 396 | * Sign one site- and user-bound deletion plan. |
| 397 | * |
| 398 | * @param array<string,mixed> $plan Deletion plan. |
| 399 | * |
| 400 | * @return string HMAC signature. |
| 401 | */ |
| 402 | private function sign_plan( $plan ) { |
| 403 | return hash_hmac( 'sha256', wp_json_encode( $plan ), wp_salt( 'nonce' ) ); |
| 404 | } |
| 405 | |
| 406 | /** |
| 407 | * Validate a signed deletion plan envelope and bounded shape. |
| 408 | * |
| 409 | * @param array<string,mixed> $plan Submitted plan. |
| 410 | * @param string $token Submitted signature. |
| 411 | * |
| 412 | * @return bool True when authentic, current, and site/user bound. |
| 413 | */ |
| 414 | private function is_valid_plan_envelope( $plan, $token ) { |
| 415 | return '' !== $token |
| 416 | && isset( $plan['version'], $plan['mode'], $plan['site_id'], $plan['user_id'], $plan['expires_at'], $plan['services'] ) |
| 417 | && 1 === absint( $plan['version'] ) |
| 418 | && 'delete' === $plan['mode'] |
| 419 | && get_current_blog_id() === absint( $plan['site_id'] ) |
| 420 | && get_current_user_id() === absint( $plan['user_id'] ) |
| 421 | && time() <= absint( $plan['expires_at'] ) |
| 422 | && is_array( $plan['services'] ) |
| 423 | && ! empty( $plan['services'] ) |
| 424 | && self::MAX_SELECTION >= count( $plan['services'] ) |
| 425 | && hash_equals( $this->sign_plan( $plan ), $token ); |
| 426 | } |
| 427 | |
| 428 | /** |
| 429 | * Restore before-images for Services deleted before a batch failure. |
| 430 | * |
| 431 | * @param array<int,int> $deleted_ids Deleted Service IDs. |
| 432 | * @param array<int,array<string,mixed>> $before_images Before-images keyed by ID. |
| 433 | * |
| 434 | * @return true|WP_Error True when restored, otherwise a compensation error. |
| 435 | */ |
| 436 | private function compensate_deleted_services( $deleted_ids, $before_images ) { |
| 437 | foreach ( array_reverse( $deleted_ids ) as $service_id ) { |
| 438 | $result = $this->repository->restore_deleted_service( $before_images[ $service_id ] ); |
| 439 | if ( is_wp_error( $result ) ) { |
| 440 | return new WP_Error( 'wpbc_service_delete_compensation_failed', __( 'The Service deletion was interrupted and could not be fully restored. Restore the affected Services from a database backup.', 'booking' ) ); |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | return true; |
| 445 | } |
| 446 | } |
| 447 |