PluginProbe
Booking Calendar / 11.7
Booking Calendar v11.7
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-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

447 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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