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 / repository / class-wpbc-appointment-services-repository.php

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

811 lines 35.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /** Native WordPress database repository for Services. @package Booking Calendar */
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 /** Native persistence adapter for Services, assignments, and Appointment snapshots. */
6 class WPBC_Appointment_Services_Repository {
7 /**
8 * Determine whether the native repository tables are available.
9 *
10 * @return bool True when every required table exists.
11 */
12 public function is_ready() { return wpbc_appointment_services_tables_exist(); }
13
14 /**
15 * Resolve the owner applied to Service reads and writes.
16 *
17 * @return int MultiUser owner ID, or zero for site-wide ownership.
18 */
19 private function owner_user_id() { return wpbc_appointment_services_get_owner_user_id(); }
20
21 /**
22 * Determine whether owner scoping can be omitted for the current user.
23 *
24 * @return bool True when the user may view all Service owners.
25 */
26 private function can_view_all_owners() { return wpbc_appointment_services_can_view_all_owners(); }
27
28 /**
29 * Build owner-aware SQL conditions for administrator Service catalogs.
30 *
31 * Provider assignments use EXISTS so one Service is counted and returned only
32 * once even if assignment storage is extended with additional matching rows.
33 * Table names are internal constants; every dynamic value is returned as a
34 * separate placeholder argument for $wpdb->prepare().
35 *
36 * @param array<string,mixed> $query Search, status, and resource_id values.
37 * @param bool $include_status Whether to restrict the query to one status.
38 *
39 * @return array{where:array<int,string>,args:array<int,mixed>} SQL conditions and arguments.
40 */
41 private function build_catalog_where( $query, $include_status = true ) {
42 global $wpdb;
43
44 $query = wp_parse_args(
45 $query,
46 array(
47 'search' => '',
48 'status' => 'active',
49 'resource_id' => 0,
50 )
51 );
52 $where = array( '1=1' );
53 $args = array();
54
55 if ( ! $this->can_view_all_owners() ) {
56 $where[] = 's.owner_user_id = %d';
57 $args[] = $this->owner_user_id();
58 }
59
60 $status = is_scalar( $query['status'] ) ? sanitize_key( $query['status'] ) : '';
61 if ( $include_status && in_array( $status, array( 'active', 'inactive', 'archived' ), true ) ) {
62 $where[] = 's.status = %s';
63 $args[] = $status;
64 }
65
66 $search = is_scalar( $query['search'] ) ? sanitize_text_field( $query['search'] ) : '';
67 if ( '' !== $search ) {
68 $like = '%' . $wpdb->esc_like( $search ) . '%';
69 $where[] = '(s.title LIKE %s OR s.description LIKE %s)';
70 $args[] = $like;
71 $args[] = $like;
72 }
73
74 $resource_id = is_scalar( $query['resource_id'] ) ? absint( $query['resource_id'] ) : 0;
75 if ( $resource_id ) {
76 $where[] = 'EXISTS ( SELECT 1 FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' sr WHERE sr.service_id = s.service_id AND sr.resource_id = %d AND sr.status = %s )';
77 $args[] = $resource_id;
78 $args[] = 'active';
79 }
80
81 return array(
82 'where' => $where,
83 'args' => $args,
84 );
85 }
86
87 /**
88 * Find one owner-visible Service and its active Provider assignments.
89 *
90 * @param int $service_id Service ID.
91 *
92 * @return array<string,mixed>|WP_Error Service row or a not-found/storage error.
93 */
94 public function find( $service_id ) {
95 global $wpdb;
96 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
97 $where = 'service_id = %d';
98 $args = array( absint( $service_id ) );
99 if ( ! $this->can_view_all_owners() ) { $where .= ' AND owner_user_id = %d'; $args[] = $this->owner_user_id(); }
100 $sql = 'SELECT * FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' WHERE ' . $where . ' LIMIT 1';
101 $row = $wpdb->get_row( $wpdb->prepare( $sql, $args ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
102 if ( ! $row ) { return new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) ); }
103 $row['resource_ids'] = $wpdb->get_col( $wpdb->prepare( 'SELECT resource_id FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE service_id = %d AND status = %s ORDER BY priority, assignment_id', $service_id, 'active' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
104 return $row;
105 }
106
107 /**
108 * List owner-visible Services matching search, status, and Provider filters.
109 *
110 * @param array<string,mixed> $query Search, status, resource_id, sorting, and pagination values.
111 *
112 * @return array<int,array<string,mixed>>|WP_Error Service rows or a storage error.
113 */
114 public function list_items( $query = array() ) {
115 global $wpdb;
116 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
117 $query = wp_parse_args( $query, array( 'search' => '', 'status' => 'active', 'resource_id' => 0, 'sort_by' => 'service_id', 'sort_order' => 'desc', 'limit' => 500, 'offset' => 0 ) );
118 $query_parts = $this->build_catalog_where( $query, true );
119 $limit = min( 500, max( 1, is_scalar( $query['limit'] ) ? absint( $query['limit'] ) : 500 ) );
120 $offset = is_scalar( $query['offset'] ) ? absint( $query['offset'] ) : 0;
121 $sort_columns = array(
122 'service_id' => 's.service_id',
123 'title' => 's.title',
124 'duration' => 's.duration_minutes',
125 'price' => 's.base_cost',
126 'status' => 's.status',
127 );
128 $sort_by = is_scalar( $query['sort_by'] ) ? sanitize_key( $query['sort_by'] ) : 'service_id';
129 $sort_column = isset( $sort_columns[ $sort_by ] ) ? $sort_columns[ $sort_by ] : $sort_columns['service_id'];
130 $sort_order = is_scalar( $query['sort_order'] ) && 'asc' === strtolower( sanitize_key( $query['sort_order'] ) ) ? 'ASC' : 'DESC';
131 $stable_order = 'service_id' === $sort_by ? '' : ', s.service_id ' . $sort_order;
132 $sql = 'SELECT s.* FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' s WHERE ' . implode( ' AND ', $query_parts['where'] ) . ' ORDER BY ' . $sort_column . ' ' . $sort_order . $stable_order . ' LIMIT %d OFFSET %d';
133 $sql_args = array_merge( $query_parts['args'], array( $limit, $offset ) );
134 $sql = $wpdb->prepare( $sql, $sql_args ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
135 $rows = (array) $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
136 if ( empty( $rows ) ) { return $rows; }
137
138 $service_ids = array_values( array_filter( array_map( 'absint', wp_list_pluck( $rows, 'service_id' ) ) ) );
139 $assignments = array();
140 if ( $service_ids ) {
141 $placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%d' ) );
142 $assignment_sql = 'SELECT service_id, resource_id FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE status = %s AND service_id IN (' . $placeholders . ') ORDER BY priority, assignment_id';
143 $assignment_args = array_merge( array( 'active' ), $service_ids );
144 foreach ( (array) $wpdb->get_results( $wpdb->prepare( $assignment_sql, $assignment_args ), ARRAY_A ) as $assignment ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
145 $service_id = absint( $assignment['service_id'] );
146 if ( ! isset( $assignments[ $service_id ] ) ) { $assignments[ $service_id ] = array(); }
147 $assignments[ $service_id ][] = absint( $assignment['resource_id'] );
148 }
149 }
150 foreach ( $rows as &$row ) {
151 $service_id = absint( $row['service_id'] );
152 $row['resource_ids'] = isset( $assignments[ $service_id ] ) ? $assignments[ $service_id ] : array();
153 }
154 unset( $row );
155 return $rows;
156 }
157
158 /**
159 * Count owner-visible Services by status for the active search and Provider filters.
160 *
161 * The requested status is intentionally ignored so the Services screen can
162 * show exact All, Active, Draft, and Archived totals with one grouped query.
163 *
164 * @param array<string,mixed> $query Search and resource_id filter values.
165 *
166 * @return array<string,int>|WP_Error Counts keyed by all, active, inactive, and archived.
167 */
168 public function count_items( $query = array() ) {
169 global $wpdb;
170 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
171
172 $query_parts = $this->build_catalog_where( $query, false );
173 $sql = 'SELECT s.status, COUNT(*) AS items_count FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' s WHERE ' . implode( ' AND ', $query_parts['where'] ) . ' GROUP BY s.status';
174 if ( $query_parts['args'] ) {
175 $sql = $wpdb->prepare( $sql, $query_parts['args'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
176 }
177
178 $counts = array(
179 'all' => 0,
180 'active' => 0,
181 'inactive' => 0,
182 'archived' => 0,
183 );
184 foreach ( (array) $wpdb->get_results( $sql, ARRAY_A ) as $count_row ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
185 $status = isset( $count_row['status'] ) ? sanitize_key( $count_row['status'] ) : '';
186 if ( isset( $counts[ $status ] ) ) {
187 $counts[ $status ] = absint( $count_row['items_count'] );
188 }
189 }
190 $counts['all'] = $counts['active'] + $counts['inactive'] + $counts['archived'];
191
192 return $counts;
193 }
194
195 /**
196 * List active public Services assigned to one Provider resource.
197 *
198 * Resource assignment is the public visibility boundary; administrator owner
199 * scoping is intentionally not applied to this catalogue lookup.
200 *
201 * @param int $resource_id Provider resource ID.
202 *
203 * @return array<int,array<string,mixed>> Active Service rows.
204 */
205 public function list_active_for_resource( $resource_id ) {
206 global $wpdb;
207 if ( ! $this->is_ready() ) { return array(); }
208 $sql = 'SELECT s.*, sr.duration_override, sr.cost_override FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' s INNER JOIN ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' sr ON sr.service_id = s.service_id WHERE s.status = %s AND sr.status = %s AND sr.resource_id = %d ORDER BY sr.priority, s.title, s.service_id';
209 $rows = (array) $wpdb->get_results( $wpdb->prepare( $sql, 'active', 'active', absint( $resource_id ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
210
211 return array_map( 'wpbc_appointment_services_apply_assignment_overrides', $rows );
212 }
213
214 /**
215 * Create or update a Service and replace its Provider assignments.
216 *
217 * @param array<string,mixed> $service Raw or normalized Service values.
218 *
219 * @return array<string,mixed>|WP_Error Saved Service or persistence error.
220 */
221 public function save( $service ) {
222 global $wpdb;
223 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
224 $raw_service = is_object( $service ) ? get_object_vars( $service ) : (array) $service;
225 $service = wpbc_appointment_services_sanitize_payload( $raw_service );
226 $service_id = absint( $service['service_id'] );
227 $metadata = wpbc_appointment_services_decode_metadata( isset( $raw_service['metadata'] ) ? $raw_service['metadata'] : array() );
228 $existing = array();
229
230 if ( $service_id ) {
231 $existing = $this->find( $service_id );
232 if ( is_wp_error( $existing ) ) { return $existing; }
233 $metadata = wpbc_appointment_services_decode_metadata( isset( $existing['metadata'] ) ? $existing['metadata'] : array() );
234 }
235 if ( ! wpbc_appointment_services_is_pricing_available() ) {
236 // A downgrade must not erase a price that can become active after upgrading again.
237 $service['base_cost'] = isset( $existing['base_cost'] ) && is_numeric( $existing['base_cost'] )
238 ? number_format( min( 9999999999.99, max( 0, (float) $existing['base_cost'] ) ), 2, '.', '' )
239 : '0.00';
240 }
241
242 if ( '' !== $service['picture_url'] ) {
243 $metadata['picture_url'] = $service['picture_url'];
244 } else {
245 unset( $metadata['picture_url'] );
246 }
247 $metadata['schema_version'] = max( 1, isset( $metadata['schema_version'] ) ? absint( $metadata['schema_version'] ) : 0 );
248 $encoded_metadata = wp_json_encode( $metadata );
249 if ( false === $encoded_metadata ) {
250 $encoded_metadata = wp_json_encode( array( 'schema_version' => 1 ) );
251 }
252
253 $now = current_time( 'mysql' ); $user_id = get_current_user_id();
254 $data = array(
255 'title' => $service['title'], 'description' => $service['description'], 'duration_minutes' => $service['duration_minutes'],
256 'buffer_before_minutes' => $service['buffer_before_minutes'], 'buffer_after_minutes' => $service['buffer_after_minutes'],
257 'base_cost' => $service['base_cost'], 'booking_form_id' => $service['booking_form_id'],
258 'status' => $service['status'], 'metadata' => $encoded_metadata, 'modified_by' => $user_id, 'modification_date' => $now,
259 );
260 $formats = array( '%s', '%s', '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%d', '%s' );
261 if ( $service_id ) {
262 $result = $wpdb->update( wpbc_appointment_services_table_name( 'services' ), $data, array( 'service_id' => $service_id ), $formats, array( '%d' ) );
263 } else {
264 $data['owner_user_id'] = $this->owner_user_id(); $data['created_by'] = $user_id; $data['creation_date'] = $now;
265 $formats[] = '%d'; $formats[] = '%d'; $formats[] = '%s';
266 $result = $wpdb->insert( wpbc_appointment_services_table_name( 'services' ), $data, $formats );
267 $service_id = absint( $wpdb->insert_id );
268 }
269 if ( false === $result || ! $service_id ) { return new WP_Error( 'service_save_failed', __( 'The Service could not be saved.', 'booking' ) ); }
270 $result = $this->replace_resources( $service_id, $service['resource_ids'] );
271 if ( is_wp_error( $result ) ) { return $result; }
272 return $this->find( $service_id );
273 }
274
275 /**
276 * Replace every active Provider assignment for a Service.
277 *
278 * @param int $service_id Service ID.
279 * @param int[] $resource_ids Requested Provider resource IDs.
280 *
281 * @return true|WP_Error True on success or an assignment persistence error.
282 */
283 private function replace_resources( $service_id, $resource_ids ) {
284 global $wpdb;
285 $service_id = absint( $service_id );
286 $resource_ids = array_values( array_unique( array_intersect( array_map( 'absint', (array) $resource_ids ), array_keys( wpbc_appointment_services_get_provider_options() ) ) ) );
287 $existing_resources = array();
288 $existing_rows = (array) $wpdb->get_results(
289 $wpdb->prepare(
290 'SELECT resource_id FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE service_id = %d',
291 $service_id
292 ),
293 ARRAY_A
294 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
295 foreach ( $existing_rows as $existing_row ) {
296 $existing_resources[] = absint( $existing_row['resource_id'] );
297 }
298 foreach ( $resource_ids as $priority => $resource_id ) {
299 if ( in_array( $resource_id, $existing_resources, true ) ) {
300 $result = $wpdb->update(
301 wpbc_appointment_services_table_name( 'service_resources' ),
302 array( 'priority' => $priority, 'status' => 'active' ),
303 array( 'service_id' => $service_id, 'resource_id' => $resource_id ),
304 array( '%d', '%s' ),
305 array( '%d', '%d' )
306 );
307 } else {
308 $result = $wpdb->insert(
309 wpbc_appointment_services_table_name( 'service_resources' ),
310 array( 'service_id' => $service_id, 'resource_id' => $resource_id, 'priority' => $priority, 'status' => 'active' ),
311 array( '%d', '%d', '%d', '%s' )
312 );
313 }
314 if ( false === $result ) { return new WP_Error( 'service_assignment_failed', __( 'Provider assignments could not be saved.', 'booking' ) ); }
315 }
316
317 $removed_resources = array_values( array_diff( $existing_resources, $resource_ids ) );
318 if ( $removed_resources ) {
319 $placeholders = implode( ',', array_fill( 0, count( $removed_resources ), '%d' ) );
320 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE service_id = %d AND resource_id IN (' . $placeholders . ')';
321 $result = $wpdb->query( $wpdb->prepare( $sql, array_merge( array( $service_id ), $removed_resources ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
322 if ( false === $result ) { return new WP_Error( 'service_assignment_failed', __( 'Provider assignments could not be saved.', 'booking' ) ); }
323 }
324 return true;
325 }
326
327 /**
328 * Duplicate a Service as an inactive copy.
329 *
330 * @param int $service_id Source Service ID.
331 *
332 * @return array<string,mixed>|WP_Error Duplicated Service or an error.
333 */
334 public function duplicate( $service_id ) {
335 $source = $this->find( $service_id );
336 if ( is_wp_error( $source ) ) { return $source; }
337 $source['service_id'] = 0; $source['title'] = sprintf( __( '%s (Copy)', 'booking' ), $source['title'] ); $source['status'] = 'inactive';
338 return $this->save( $source );
339 }
340
341 /**
342 * Archive one owner-visible Service.
343 *
344 * @param int $service_id Service ID.
345 *
346 * @return bool|WP_Error True on success or an error.
347 */
348 public function archive( $service_id ) {
349 $service = $this->find( $service_id );
350 if ( is_wp_error( $service ) ) { return $service; }
351 $service['status'] = 'archived';
352 return is_wp_error( $this->save( $service ) ) ? new WP_Error( 'service_archive_failed', __( 'The Service could not be archived.', 'booking' ) ) : true;
353 }
354
355 /**
356 * Load the complete owner-authorized before-image required for safe deletion.
357 *
358 * @param int $service_id Service ID.
359 *
360 * @return array<string,mixed>|WP_Error Canonical Service and assignment rows, or an error.
361 */
362 public function get_deletion_before_image( $service_id ) {
363 global $wpdb;
364
365 $service_id = absint( $service_id );
366 $authorized = $this->find( $service_id );
367 if ( is_wp_error( $authorized ) ) {
368 return $authorized;
369 }
370
371 $service = $wpdb->get_row(
372 $wpdb->prepare(
373 'SELECT * FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' WHERE service_id = %d LIMIT 1',
374 $service_id
375 ),
376 ARRAY_A
377 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
378 if ( ! $service ) {
379 return new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) );
380 }
381
382 $assignments = $wpdb->get_results(
383 $wpdb->prepare(
384 'SELECT * FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE service_id = %d ORDER BY assignment_id',
385 $service_id
386 ),
387 ARRAY_A
388 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
389 if ( null === $assignments ) {
390 return new WP_Error( 'appointment_services_storage_unavailable', __( 'Provider assignments could not be audited safely.', 'booking' ) );
391 }
392
393 return array(
394 'service' => $service,
395 'assignments' => $assignments,
396 );
397 }
398
399 /**
400 * Audit every native reference that can make permanent Service deletion unsafe.
401 *
402 * Provider assignments are owned children and are reported for review. Existing
403 * appointment snapshots and saved Appointment shortcode or block configuration
404 * are independent consumers and therefore block deletion.
405 *
406 * @param int $service_id Service ID.
407 *
408 * @return array<string,mixed>|WP_Error Reference counts and confirmed post references, or an error.
409 */
410 public function get_deletion_impact( $service_id ) {
411 $service_id = absint( $service_id );
412 $impacts = $this->get_deletion_impacts( array( $service_id ) );
413
414 if ( is_wp_error( $impacts ) ) {
415 return $impacts;
416 }
417
418 return isset( $impacts[ $service_id ] )
419 ? $impacts[ $service_id ]
420 : new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) );
421 }
422
423 /**
424 * Audit deletion references for a bounded Service selection in batch.
425 *
426 * Appointment snapshot counts and saved post candidates are loaded once for
427 * the complete selection. This keeps bulk deletion review independent from
428 * selection size and avoids one full saved-content scan per Service.
429 *
430 * @param array<int,int> $service_ids Service IDs to audit.
431 * @param array<int,array<string,mixed>>|null $before_images Optional authorized before-images keyed by Service ID.
432 *
433 * @return array<int,array<string,mixed>>|WP_Error Impacts keyed by Service ID, or a safe audit error.
434 */
435 public function get_deletion_impacts( $service_ids, $before_images = null ) {
436 global $wpdb;
437
438 $service_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $service_ids ) ) ) );
439 if ( empty( $service_ids ) ) {
440 return new WP_Error( 'wpbc_service_invalid_delete_selection', __( 'Select at least one Service.', 'booking' ) );
441 }
442
443 $before_images = is_array( $before_images ) ? $before_images : array();
444 foreach ( $service_ids as $service_id ) {
445 if ( isset( $before_images[ $service_id ] ) && is_array( $before_images[ $service_id ] ) ) {
446 continue;
447 }
448 $before_image = $this->get_deletion_before_image( $service_id );
449 if ( is_wp_error( $before_image ) ) {
450 return $before_image;
451 }
452 $before_images[ $service_id ] = $before_image;
453 }
454
455 $id_placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%d' ) );
456 $appointment_rows = $wpdb->get_results(
457 $wpdb->prepare(
458 'SELECT service_id, COUNT(*) AS reference_count FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . " WHERE service_id IN ({$id_placeholders}) GROUP BY service_id",
459 $service_ids
460 ),
461 ARRAY_A
462 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
463 if ( null === $appointment_rows ) {
464 return new WP_Error( 'appointment_services_storage_unavailable', __( 'Appointment references could not be audited safely.', 'booking' ) );
465 }
466
467 $appointment_counts = array_fill_keys( $service_ids, 0 );
468 foreach ( $appointment_rows as $appointment_row ) {
469 $appointment_service_id = absint( $appointment_row['service_id'] );
470 if ( isset( $appointment_counts[ $appointment_service_id ] ) ) {
471 $appointment_counts[ $appointment_service_id ] = absint( $appointment_row['reference_count'] );
472 }
473 }
474
475 $post_candidates = $wpdb->get_results(
476 $wpdb->prepare(
477 "SELECT ID, post_title, post_type, post_status, post_content FROM {$wpdb->posts} WHERE post_content LIKE %s AND post_status NOT IN (%s, %s, %s) ORDER BY ID",
478 '%' . $wpdb->esc_like( 'booking_appointment' ) . '%',
479 'trash',
480 'auto-draft',
481 'inherit'
482 ),
483 ARRAY_A
484 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
485 if ( null === $post_candidates ) {
486 return new WP_Error( 'appointment_services_storage_unavailable', __( 'Saved page references could not be audited safely.', 'booking' ) );
487 }
488
489 $post_references = array_fill_keys( $service_ids, array() );
490 foreach ( $post_candidates as $post_candidate ) {
491 $authorized_post_reference = array(
492 'id' => absint( $post_candidate['ID'] ),
493 'title' => sanitize_text_field( (string) $post_candidate['post_title'] ),
494 'type' => sanitize_key( (string) $post_candidate['post_type'] ),
495 'status' => sanitize_key( (string) $post_candidate['post_status'] ),
496 );
497 foreach ( $service_ids as $service_id ) {
498 if ( $this->post_content_references_service( (string) $post_candidate['post_content'], $service_id ) ) {
499 $post_references[ $service_id ][] = $authorized_post_reference;
500 }
501 }
502 }
503
504 $impacts = array();
505 foreach ( $service_ids as $service_id ) {
506 $service_post_references = $post_references[ $service_id ];
507 $impacts[ $service_id ] = array(
508 'assignment_count' => count( $before_images[ $service_id ]['assignments'] ),
509 'appointment_count' => $appointment_counts[ $service_id ],
510 'post_reference_count' => count( $service_post_references ),
511 'post_references' => $service_post_references,
512 'is_complete' => true,
513 );
514 }
515
516 return $impacts;
517 }
518
519 /**
520 * Determine whether saved post content explicitly names one Appointment Service.
521 *
522 * @param string $post_content Saved block or shortcode content.
523 * @param int $service_id Service ID.
524 *
525 * @return bool True when an Appointment shortcode or block contains the ID.
526 */
527 private function post_content_references_service( $post_content, $service_id ) {
528 $service_id = absint( $service_id );
529 if ( ! $service_id || '' === $post_content || false === stripos( $post_content, 'booking_appointment' ) ) {
530 return false;
531 }
532
533 $single_patterns = array(
534 '/\bservice_id\s*=\s*["\']?' . $service_id . '(?:["\']|\s|\]|$)/i',
535 '/["\']service_id["\']\s*:\s*["\']?' . $service_id . '(?:["\']|\s|,|\}|$)/i',
536 );
537 foreach ( $single_patterns as $single_pattern ) {
538 if ( preg_match( $single_pattern, $post_content ) ) {
539 return true;
540 }
541 }
542
543 $list_patterns = array(
544 '/\bservice_ids\s*=\s*["\']([^"\']*)["\']/i',
545 '/\bservices\s*=\s*["\']([^"\']*)["\']/i',
546 '/["\']service_ids["\']\s*:\s*\[([^\]]*)\]/i',
547 '/["\']services["\']\s*:\s*(?:\[([^\]]*)\]|["\']([^"\']*)["\'])/i',
548 );
549 foreach ( $list_patterns as $list_pattern ) {
550 if ( ! preg_match_all( $list_pattern, $post_content, $matches ) ) {
551 continue;
552 }
553 $matched_lists = isset( $matches[1] ) ? $matches[1] : array();
554 if ( isset( $matches[2] ) ) {
555 foreach ( $matches[2] as $match_index => $alternate_match ) {
556 if ( '' !== $alternate_match ) {
557 $matched_lists[ $match_index ] = $alternate_match;
558 }
559 }
560 }
561 foreach ( $matched_lists as $matched_list ) {
562 $matched_ids = array_values( array_filter( array_map( 'absint', preg_split( '/[^0-9]+/', (string) $matched_list ) ) ) );
563 if ( in_array( $service_id, $matched_ids, true ) ) {
564 return true;
565 }
566 }
567 }
568
569 return false;
570 }
571
572 /**
573 * Start a native database transaction for a reviewed deletion batch.
574 *
575 * @return bool True when the transaction command succeeded.
576 */
577 public function begin_transaction() {
578 global $wpdb;
579 return false !== $wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
580 }
581
582 /**
583 * Commit the active native database transaction.
584 *
585 * @return bool True when the commit command succeeded.
586 */
587 public function commit_transaction() {
588 global $wpdb;
589 return false !== $wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
590 }
591
592 /**
593 * Roll back the active native database transaction.
594 *
595 * @return bool True when the rollback command succeeded.
596 */
597 public function rollback_transaction() {
598 global $wpdb;
599 return false !== $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
600 }
601
602 /**
603 * Delete one Service only when its reviewed canonical row remains unchanged.
604 *
605 * @param array<string,mixed> $before_image Canonical before-image from get_deletion_before_image().
606 *
607 * @return true|WP_Error True on success, or a stale/storage error.
608 */
609 public function compare_and_delete_service( $before_image ) {
610 global $wpdb;
611
612 $service = isset( $before_image['service'] ) && is_array( $before_image['service'] ) ? $before_image['service'] : array();
613 $service_id = isset( $service['service_id'] ) ? absint( $service['service_id'] ) : 0;
614 if ( ! $service_id ) {
615 return new WP_Error( 'wpbc_service_invalid_delete_before_image', __( 'The reviewed Service deletion is invalid.', 'booking' ) );
616 }
617
618 $assignment_result = $wpdb->delete(
619 wpbc_appointment_services_table_name( 'service_resources' ),
620 array( 'service_id' => $service_id ),
621 array( '%d' )
622 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
623 if ( false === $assignment_result ) {
624 return new WP_Error( 'wpbc_service_delete_failed', __( 'Provider assignments could not be removed.', 'booking' ) );
625 }
626
627 $delete_result = $wpdb->delete(
628 wpbc_appointment_services_table_name( 'services' ),
629 array(
630 'service_id' => $service_id,
631 'owner_user_id' => absint( $service['owner_user_id'] ),
632 'modification_date' => (string) $service['modification_date'],
633 ),
634 array( '%d', '%d', '%s' )
635 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
636 if ( 1 !== $delete_result ) {
637 $this->restore_deleted_service( $before_image );
638 return new WP_Error( 'wpbc_service_stale_delete_apply', __( 'The Service changed after review. Review the deletion again.', 'booking' ) );
639 }
640
641 return true;
642 }
643
644 /**
645 * Restore a deleted Service and its assignments after a partial batch failure.
646 *
647 * @param array<string,mixed> $before_image Canonical deletion before-image.
648 *
649 * @return true|WP_Error True on success, or a compensation error.
650 */
651 public function restore_deleted_service( $before_image ) {
652 global $wpdb;
653
654 $service = isset( $before_image['service'] ) && is_array( $before_image['service'] ) ? $before_image['service'] : array();
655 $service_id = isset( $service['service_id'] ) ? absint( $service['service_id'] ) : 0;
656 if ( ! $service_id ) {
657 return new WP_Error( 'wpbc_service_delete_compensation_failed', __( 'A deleted Service could not be restored.', 'booking' ) );
658 }
659
660 $exists = $wpdb->get_var(
661 $wpdb->prepare(
662 'SELECT service_id FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' WHERE service_id = %d',
663 $service_id
664 )
665 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
666 if ( ! $exists && false === $wpdb->insert( wpbc_appointment_services_table_name( 'services' ), $service ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
667 return new WP_Error( 'wpbc_service_delete_compensation_failed', __( 'A deleted Service could not be restored.', 'booking' ) );
668 }
669
670 $assignments = isset( $before_image['assignments'] ) && is_array( $before_image['assignments'] ) ? $before_image['assignments'] : array();
671 foreach ( $assignments as $assignment ) {
672 $assignment_exists = $wpdb->get_var(
673 $wpdb->prepare(
674 'SELECT assignment_id FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE assignment_id = %d',
675 absint( $assignment['assignment_id'] )
676 )
677 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
678 if ( ! $assignment_exists && false === $wpdb->insert( wpbc_appointment_services_table_name( 'service_resources' ), $assignment ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
679 return new WP_Error( 'wpbc_service_delete_compensation_failed', __( 'Deleted Provider assignments could not be restored.', 'booking' ) );
680 }
681 }
682
683 return true;
684 }
685
686 /**
687 * Find an active Service with an active assignment to a Provider.
688 *
689 * @param int $service_id Service ID.
690 * @param int $resource_id Provider resource ID.
691 *
692 * @return array<string,mixed>|WP_Error Service row or compatibility/storage error.
693 */
694 public function find_active_for_resource( $service_id, $resource_id ) {
695 global $wpdb;
696 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
697 $sql = 'SELECT s.*, sr.duration_override, sr.cost_override FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' s INNER JOIN ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' sr ON sr.service_id = s.service_id WHERE s.service_id = %d AND s.status = %s AND sr.resource_id = %d AND sr.status = %s LIMIT 1';
698 $service = $wpdb->get_row( $wpdb->prepare( $sql, absint( $service_id ), 'active', absint( $resource_id ), 'active' ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
699
700 return $service ? wpbc_appointment_services_apply_assignment_overrides( $service ) : new WP_Error( 'service_provider_mismatch', __( 'The selected Service is not available from this Provider.', 'booking' ) );
701 }
702
703 /**
704 * Insert or update the immutable Service values attached to an Appointment.
705 *
706 * @param int $booking_id Core booking ID.
707 * @param int $resource_id Provider resource ID.
708 * @param array<string,mixed> $service Service values copied into the snapshot.
709 *
710 * @return bool True when the snapshot was persisted.
711 */
712 public function save_appointment_snapshot( $booking_id, $resource_id, $service ) {
713 global $wpdb;
714 if ( ! $this->is_ready() || ! absint( $booking_id ) || ! absint( $resource_id ) || empty( $service['service_id'] ) ) {
715 return false;
716 }
717 $service = wp_parse_args(
718 (array) $service,
719 array(
720 'title' => '',
721 'duration_minutes' => 0,
722 'buffer_before_minutes'=> 0,
723 'buffer_after_minutes' => 0,
724 'base_cost' => '0.00',
725 'booking_form_id' => 0,
726 )
727 );
728 if ( ! wpbc_appointment_services_is_pricing_available() ) {
729 // New snapshots below Business Small must not imply an unsupported charge.
730 $service['base_cost'] = '0.00';
731 $service['base_service_cost'] = '0.00';
732 $service['cost_override'] = null;
733 }
734 $existing_id = $wpdb->get_var( $wpdb->prepare( 'SELECT appointment_detail_id FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' WHERE booking_id = %d', $booking_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
735 if ( $existing_id ) {
736 return true;
737 }
738
739 $now = current_time( 'mysql' );
740 $base_service_cost = isset( $service['base_service_cost'] ) && is_numeric( $service['base_service_cost'] )
741 ? number_format( min( 9999999999.99, max( 0, (float) $service['base_service_cost'] ) ), 2, '.', '' )
742 : number_format( min( 9999999999.99, max( 0, (float) $service['base_cost'] ) ), 2, '.', '' );
743 $cost_override = isset( $service['cost_override'] ) && is_numeric( $service['cost_override'] )
744 ? number_format( min( 9999999999.99, max( 0, (float) $service['cost_override'] ) ), 2, '.', '' )
745 : null;
746 $metadata = array(
747 'schema_version' => 2,
748 'provider_title' => wpbc_appointment_services_get_provider_title( $resource_id ),
749 'base_duration_minutes' => isset( $service['base_duration_minutes'] ) ? min( 65535, absint( $service['base_duration_minutes'] ) ) : min( 65535, absint( $service['duration_minutes'] ) ),
750 'duration_override_minutes' => isset( $service['duration_override_minutes'] ) ? min( 65535, absint( $service['duration_override_minutes'] ) ) : 0,
751 'base_service_cost' => $base_service_cost,
752 'cost_override' => $cost_override,
753 );
754 $data = array(
755 'booking_id' => absint( $booking_id ), 'service_id' => absint( $service['service_id'] ), 'resource_id' => absint( $resource_id ),
756 'service_title' => sanitize_text_field( $service['title'] ), 'duration_minutes' => min( 65535, absint( $service['duration_minutes'] ) ),
757 'buffer_before_minutes' => min( 65535, absint( $service['buffer_before_minutes'] ) ), 'buffer_after_minutes' => min( 65535, absint( $service['buffer_after_minutes'] ) ),
758 'base_cost' => number_format( min( 9999999999.99, max( 0, (float) $service['base_cost'] ) ), 2, '.', '' ), 'booking_form_id' => absint( $service['booking_form_id'] ),
759 'metadata' => wp_json_encode( $metadata ), 'creation_date' => $now, 'modification_date' => $now,
760 );
761 $formats = array( '%d', '%d', '%d', '%s', '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s' );
762 $inserted = $wpdb->insert( wpbc_appointment_services_table_name( 'appointment_details' ), $data, $formats );
763 if ( false !== $inserted ) {
764 return true;
765 }
766
767 // A concurrent duplicate hook may have inserted the immutable row first.
768 return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT appointment_detail_id FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' WHERE booking_id = %d', $booking_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
769 }
770
771 /**
772 * Return the Appointment Service snapshot for a core booking.
773 *
774 * @param int $booking_id Core booking ID.
775 *
776 * @return array<string,mixed>|null Snapshot row, or null when absent/unavailable.
777 */
778 public function get_appointment_snapshot( $booking_id ) {
779 global $wpdb;
780 if ( ! $this->is_ready() ) { return null; }
781 $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' WHERE booking_id = %d LIMIT 1', absint( $booking_id ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
782 return $row ? $row : null;
783 }
784
785 /**
786 * Update the Provider stored in an Appointment snapshot after a valid move.
787 *
788 * @param int $booking_id Core booking ID.
789 * @param int $resource_id New Provider resource ID.
790 *
791 * @return bool True when the database update did not fail.
792 */
793 public function update_snapshot_resource( $booking_id, $resource_id ) {
794 global $wpdb;
795 $snapshot = $this->get_appointment_snapshot( $booking_id );
796 if ( ! $snapshot ) {
797 return true;
798 }
799 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
800 $metadata['schema_version'] = max( 2, isset( $metadata['schema_version'] ) ? absint( $metadata['schema_version'] ) : 0 );
801 $metadata['provider_title'] = wpbc_appointment_services_get_provider_title( $resource_id );
802
803 return false !== $wpdb->update(
804 wpbc_appointment_services_table_name( 'appointment_details' ),
805 array( 'resource_id' => absint( $resource_id ), 'metadata' => wp_json_encode( $metadata ), 'modification_date' => current_time( 'mysql' ) ),
806 array( 'booking_id' => absint( $booking_id ) ),
807 array( '%d', '%s', '%s' ), array( '%d' )
808 );
809 }
810 }
811