PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-appointment-services / repository / class-wpbc-appointment-services-repository.php

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

1,106 lines 47.6 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 $force_current_owner = ! empty( $query['force_current_owner'] );
56 if ( $force_current_owner || ! $this->can_view_all_owners() ) {
57 $where[] = 's.owner_user_id = %d';
58 $args[] = $this->owner_user_id();
59 }
60
61 $status = is_scalar( $query['status'] ) ? sanitize_key( $query['status'] ) : '';
62 if ( $include_status && in_array( $status, array( 'active', 'inactive', 'archived' ), true ) ) {
63 $where[] = 's.status = %s';
64 $args[] = $status;
65 }
66
67 $search = is_scalar( $query['search'] ) ? sanitize_text_field( $query['search'] ) : '';
68 if ( '' !== $search ) {
69 $like = '%' . $wpdb->esc_like( $search ) . '%';
70 $where[] = '(s.title LIKE %s OR s.description LIKE %s)';
71 $args[] = $like;
72 $args[] = $like;
73 }
74
75 $resource_id = is_scalar( $query['resource_id'] ) ? absint( $query['resource_id'] ) : 0;
76 if ( $resource_id ) {
77 $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 )';
78 $args[] = $resource_id;
79 $args[] = 'active';
80 }
81
82 return array(
83 'where' => $where,
84 'args' => $args,
85 );
86 }
87
88 /**
89 * Find one owner-visible Service and its active Provider assignments.
90 *
91 * @param int $service_id Service ID.
92 *
93 * @return array<string,mixed>|WP_Error Service row or a not-found/storage error.
94 */
95 public function find( $service_id ) {
96 global $wpdb;
97 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
98 $where = 'service_id = %d';
99 $args = array( absint( $service_id ) );
100 if ( ! $this->can_view_all_owners() ) { $where .= ' AND owner_user_id = %d'; $args[] = $this->owner_user_id(); }
101 $sql = 'SELECT * FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' WHERE ' . $where . ' LIMIT 1';
102 $row = $wpdb->get_row( $wpdb->prepare( $sql, $args ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
103 if ( ! $row ) { return new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) ); }
104 $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
105 return $row;
106 }
107
108 /**
109 * Find a Service previously created by one Setup Wizard save operation.
110 *
111 * The progressive-save coordinator writes canonical data before it commits
112 * checkpoint metadata. If that checkpoint commit fails, a request retry must
113 * reuse the already-created Service instead of inserting a duplicate. The
114 * lookup is always restricted to the current Setup Wizard owner, including
115 * for administrators who may otherwise view every owner in the catalog.
116 *
117 * @param string $operation_id Stable Setup Wizard operation identifier.
118 * @param string $draft_id Stable Service draft identifier within the step.
119 *
120 * @return array<string,mixed>|null|WP_Error Matching Service, null when no
121 * marker exists, or a storage error.
122 */
123 public function find_by_setup_operation( $operation_id, $draft_id ) {
124 global $wpdb;
125
126 if ( ! $this->is_ready() ) {
127 return wpbc_appointment_services_storage_error();
128 }
129
130 $operation_id = sanitize_key( (string) $operation_id );
131 $draft_id = sanitize_key( (string) $draft_id );
132 if ( '' === $operation_id || '' === $draft_id ) {
133 return null;
134 }
135
136 $metadata_like = '%' . $wpdb->esc_like( '"wpbc_setup_wizard_operation_id":"' . $operation_id . '"' ) . '%';
137 $sql = 'SELECT service_id, metadata FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' WHERE owner_user_id = %d AND metadata LIKE %s ORDER BY service_id DESC LIMIT 50';
138 $rows = (array) $wpdb->get_results(
139 $wpdb->prepare( $sql, $this->owner_user_id(), $metadata_like ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
140 ARRAY_A
141 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
142
143 foreach ( $rows as $row ) {
144 $metadata = wpbc_appointment_services_decode_metadata( isset( $row['metadata'] ) ? $row['metadata'] : array() );
145 if (
146 isset( $metadata['wpbc_setup_wizard_operation_id'], $metadata['wpbc_setup_wizard_draft_id'] )
147 && hash_equals( $operation_id, (string) $metadata['wpbc_setup_wizard_operation_id'] )
148 && hash_equals( $draft_id, (string) $metadata['wpbc_setup_wizard_draft_id'] )
149 ) {
150 return $this->find( absint( $row['service_id'] ) );
151 }
152 }
153
154 return null;
155 }
156
157 /**
158 * List owner-visible Services matching search, status, and Provider filters.
159 *
160 * @param array<string,mixed> $query Search, status, resource_id, sorting, and pagination values.
161 *
162 * @return array<int,array<string,mixed>>|WP_Error Service rows or a storage error.
163 */
164 public function list_items( $query = array() ) {
165 global $wpdb;
166 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
167 $query = wp_parse_args( $query, array( 'search' => '', 'status' => 'active', 'resource_id' => 0, 'sort_by' => 'service_id', 'sort_order' => 'desc', 'limit' => 500, 'offset' => 0 ) );
168 $query_parts = $this->build_catalog_where( $query, true );
169 $limit = min( 500, max( 1, is_scalar( $query['limit'] ) ? absint( $query['limit'] ) : 500 ) );
170 $offset = is_scalar( $query['offset'] ) ? absint( $query['offset'] ) : 0;
171 $sort_columns = array(
172 'service_id' => 's.service_id',
173 'title' => 's.title',
174 'duration' => 's.duration_minutes',
175 'price' => 's.base_cost',
176 'status' => 's.status',
177 );
178 $sort_by = is_scalar( $query['sort_by'] ) ? sanitize_key( $query['sort_by'] ) : 'service_id';
179 $sort_column = isset( $sort_columns[ $sort_by ] ) ? $sort_columns[ $sort_by ] : $sort_columns['service_id'];
180 $sort_order = is_scalar( $query['sort_order'] ) && 'asc' === strtolower( sanitize_key( $query['sort_order'] ) ) ? 'ASC' : 'DESC';
181 $stable_order = 'service_id' === $sort_by ? '' : ', s.service_id ' . $sort_order;
182 $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';
183 $sql_args = array_merge( $query_parts['args'], array( $limit, $offset ) );
184 $sql = $wpdb->prepare( $sql, $sql_args ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
185 $rows = (array) $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
186 if ( empty( $rows ) ) { return $rows; }
187
188 $service_ids = array_values( array_filter( array_map( 'absint', wp_list_pluck( $rows, 'service_id' ) ) ) );
189 $assignments = array();
190 if ( $service_ids ) {
191 $placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%d' ) );
192 $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';
193 $assignment_args = array_merge( array( 'active' ), $service_ids );
194 foreach ( (array) $wpdb->get_results( $wpdb->prepare( $assignment_sql, $assignment_args ), ARRAY_A ) as $assignment ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
195 $service_id = absint( $assignment['service_id'] );
196 if ( ! isset( $assignments[ $service_id ] ) ) { $assignments[ $service_id ] = array(); }
197 $assignments[ $service_id ][] = absint( $assignment['resource_id'] );
198 }
199 }
200 foreach ( $rows as &$row ) {
201 $service_id = absint( $row['service_id'] );
202 $row['resource_ids'] = isset( $assignments[ $service_id ] ) ? $assignments[ $service_id ] : array();
203 }
204 unset( $row );
205 return $rows;
206 }
207
208 /**
209 * List Services owned by the effective Booking Calendar owner only.
210 *
211 * Setup workflows edit one owner context even when a MultiUser super
212 * administrator may browse every owner in the catalog. The internal flag can
213 * only narrow the normal repository scope and cannot expose another owner.
214 *
215 * @param array<string,mixed> $query Search, status, sorting, and pagination values.
216 *
217 * @return array<int,array<string,mixed>>|WP_Error Service rows or a storage error.
218 */
219 public function list_items_for_current_owner( $query = array() ) {
220 $query['force_current_owner'] = true;
221
222 return $this->list_items( $query );
223 }
224
225 /**
226 * Count owner-visible Services by status for the active search and Provider filters.
227 *
228 * The requested status is intentionally ignored so the Services screen can
229 * show exact All, Active, Draft, and Archived totals with one grouped query.
230 *
231 * @param array<string,mixed> $query Search and resource_id filter values.
232 *
233 * @return array<string,int>|WP_Error Counts keyed by all, active, inactive, and archived.
234 */
235 public function count_items( $query = array() ) {
236 global $wpdb;
237 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
238
239 $query_parts = $this->build_catalog_where( $query, false );
240 $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';
241 if ( $query_parts['args'] ) {
242 $sql = $wpdb->prepare( $sql, $query_parts['args'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
243 }
244
245 $counts = array(
246 'all' => 0,
247 'active' => 0,
248 'inactive' => 0,
249 'archived' => 0,
250 );
251 foreach ( (array) $wpdb->get_results( $sql, ARRAY_A ) as $count_row ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
252 $status = isset( $count_row['status'] ) ? sanitize_key( $count_row['status'] ) : '';
253 if ( isset( $counts[ $status ] ) ) {
254 $counts[ $status ] = absint( $count_row['items_count'] );
255 }
256 }
257 $counts['all'] = $counts['active'] + $counts['inactive'] + $counts['archived'];
258
259 return $counts;
260 }
261
262 /**
263 * List active public Services assigned to one Provider resource.
264 *
265 * Resource assignment is the public visibility boundary; administrator owner
266 * scoping is intentionally not applied to this catalogue lookup.
267 *
268 * @param int $resource_id Provider resource ID.
269 *
270 * @return array<int,array<string,mixed>> Active Service rows.
271 */
272 public function list_active_for_resource( $resource_id ) {
273 global $wpdb;
274 if ( ! $this->is_ready() ) { return array(); }
275 $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';
276 $rows = (array) $wpdb->get_results( $wpdb->prepare( $sql, 'active', 'active', absint( $resource_id ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
277
278 return array_map( 'wpbc_appointment_services_apply_assignment_overrides', $rows );
279 }
280
281 /**
282 * Create or update a Service and replace its Provider assignments.
283 *
284 * @param array<string,mixed> $service Raw or normalized Service values.
285 *
286 * @return array<string,mixed>|WP_Error Saved Service or persistence error.
287 */
288 public function save( $service ) {
289 global $wpdb;
290 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
291 $raw_service = is_object( $service ) ? get_object_vars( $service ) : (array) $service;
292 $service = wpbc_appointment_services_sanitize_payload( $raw_service );
293 $service_id = absint( $service['service_id'] );
294 $is_new = ! $service_id;
295 $metadata = wpbc_appointment_services_decode_metadata( isset( $raw_service['metadata'] ) ? $raw_service['metadata'] : array() );
296 $existing = array();
297
298 if ( $service_id ) {
299 $existing = $this->find( $service_id );
300 if ( is_wp_error( $existing ) ) { return $existing; }
301 $metadata = wpbc_appointment_services_decode_metadata( isset( $existing['metadata'] ) ? $existing['metadata'] : array() );
302 }
303 if ( ! wpbc_appointment_services_is_pricing_available() ) {
304 // A downgrade must not erase a price that can become active after upgrading again.
305 $service['base_cost'] = isset( $existing['base_cost'] ) && is_numeric( $existing['base_cost'] )
306 ? number_format( min( 9999999999.99, max( 0, (float) $existing['base_cost'] ) ), 2, '.', '' )
307 : '0.00';
308 }
309
310 if ( '' !== $service['picture_url'] ) {
311 $metadata['picture_url'] = $service['picture_url'];
312 } else {
313 unset( $metadata['picture_url'] );
314 }
315 $metadata['schema_version'] = max( 1, isset( $metadata['schema_version'] ) ? absint( $metadata['schema_version'] ) : 0 );
316 $encoded_metadata = wp_json_encode( $metadata );
317 if ( false === $encoded_metadata ) {
318 $encoded_metadata = wp_json_encode( array( 'schema_version' => 1 ) );
319 }
320
321 $now = current_time( 'mysql' ); $user_id = get_current_user_id();
322 $data = array(
323 'title' => $service['title'], 'description' => $service['description'], 'duration_minutes' => $service['duration_minutes'],
324 'buffer_before_minutes' => $service['buffer_before_minutes'], 'buffer_after_minutes' => $service['buffer_after_minutes'],
325 'base_cost' => $service['base_cost'], 'booking_form_id' => $service['booking_form_id'],
326 'status' => $service['status'], 'metadata' => $encoded_metadata, 'modified_by' => $user_id, 'modification_date' => $now,
327 );
328 $formats = array( '%s', '%s', '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%d', '%s' );
329 if ( $service_id ) {
330 $result = $wpdb->update( wpbc_appointment_services_table_name( 'services' ), $data, array( 'service_id' => $service_id ), $formats, array( '%d' ) );
331 } else {
332 $data['owner_user_id'] = $this->owner_user_id(); $data['created_by'] = $user_id; $data['creation_date'] = $now;
333 $formats[] = '%d'; $formats[] = '%d'; $formats[] = '%s';
334 $result = $wpdb->insert( wpbc_appointment_services_table_name( 'services' ), $data, $formats );
335 $service_id = absint( $wpdb->insert_id );
336 }
337 if ( false === $result || ! $service_id ) { return new WP_Error( 'service_save_failed', __( 'The Service could not be saved.', 'booking' ) ); }
338 $result = $this->replace_resources( $service_id, $service['resource_ids'] );
339 if ( is_wp_error( $result ) ) {
340 if ( $is_new ) {
341 $wpdb->delete( wpbc_appointment_services_table_name( 'service_resources' ), array( 'service_id' => $service_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
342 $wpdb->delete( wpbc_appointment_services_table_name( 'services' ), array( 'service_id' => $service_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
343 }
344 return $result;
345 }
346 return $this->find( $service_id );
347 }
348
349 /**
350 * Update Service-owned fields without changing Provider assignments.
351 *
352 * The Setup Wizard Services editor does not own Provider assignments. Using
353 * {@see save()} for that editor would replace assignment rows, reactivate
354 * inactive rows, and reorder priorities even when the user changed only a
355 * Service title or duration. This narrow writer deliberately updates only the
356 * canonical Service row after the normal owner and payload validation.
357 *
358 * @param array<string,mixed>|object $service Raw or normalized Service values.
359 *
360 * @return array<string,mixed>|WP_Error Saved Service or persistence error.
361 */
362 public function update_details_preserving_assignments( $service ) {
363 global $wpdb;
364
365 if ( ! $this->is_ready() ) {
366 return wpbc_appointment_services_storage_error();
367 }
368
369 $raw_service = is_object( $service ) ? get_object_vars( $service ) : (array) $service;
370 $service = wpbc_appointment_services_sanitize_payload( $raw_service );
371 $service_id = absint( $service['service_id'] );
372 if ( ! $service_id ) {
373 return new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) );
374 }
375
376 $existing = $this->find( $service_id );
377 if ( is_wp_error( $existing ) ) {
378 return $existing;
379 }
380
381 $metadata = wpbc_appointment_services_decode_metadata( isset( $existing['metadata'] ) ? $existing['metadata'] : array() );
382 if ( ! wpbc_appointment_services_is_pricing_available() ) {
383 // A downgrade must not erase a price that can become active after upgrading again.
384 $service['base_cost'] = isset( $existing['base_cost'] ) && is_numeric( $existing['base_cost'] )
385 ? number_format( min( 9999999999.99, max( 0, (float) $existing['base_cost'] ) ), 2, '.', '' )
386 : '0.00';
387 }
388
389 if ( '' !== $service['picture_url'] ) {
390 $metadata['picture_url'] = $service['picture_url'];
391 } else {
392 unset( $metadata['picture_url'] );
393 }
394 $metadata['schema_version'] = max( 1, isset( $metadata['schema_version'] ) ? absint( $metadata['schema_version'] ) : 0 );
395 $encoded_metadata = wp_json_encode( $metadata );
396 if ( false === $encoded_metadata ) {
397 return new WP_Error( 'service_metadata_invalid', __( 'The Service metadata could not be saved.', 'booking' ) );
398 }
399
400 $result = $wpdb->update(
401 wpbc_appointment_services_table_name( 'services' ),
402 array(
403 'title' => $service['title'],
404 'description' => $service['description'],
405 'duration_minutes' => $service['duration_minutes'],
406 'buffer_before_minutes' => $service['buffer_before_minutes'],
407 'buffer_after_minutes' => $service['buffer_after_minutes'],
408 'base_cost' => $service['base_cost'],
409 'booking_form_id' => $service['booking_form_id'],
410 'status' => $service['status'],
411 'metadata' => $encoded_metadata,
412 'modified_by' => get_current_user_id(),
413 'modification_date' => current_time( 'mysql' ),
414 ),
415 array( 'service_id' => $service_id ),
416 array( '%s', '%s', '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%d', '%s' ),
417 array( '%d' )
418 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
419
420 if ( false === $result ) {
421 return new WP_Error( 'service_save_failed', __( 'The Service could not be saved.', 'booking' ) );
422 }
423
424 return $this->find( $service_id );
425 }
426
427 /**
428 * Move one owner-authorized Service to Draft without changing assignments.
429 *
430 * The Setup Wizard requires a narrow, retry-safe lifecycle transition. This
431 * writer changes only the canonical status, the wizard operation marker, and
432 * normal modification fields. Provider assignments, Booking Form assignment,
433 * Service details, and unrelated metadata remain unchanged.
434 *
435 * @param int $service_id Canonical Service ID.
436 * @param string $operation_id Stable sanitized Setup Wizard operation ID.
437 *
438 * @return array<string,mixed>|WP_Error Updated Service or persistence error.
439 */
440 public function move_to_draft_preserving_assignments( $service_id, $operation_id ) {
441 global $wpdb;
442
443 if ( ! $this->is_ready() ) {
444 return wpbc_appointment_services_storage_error();
445 }
446
447 $service_id = absint( $service_id );
448 $operation_id = sanitize_key( (string) $operation_id );
449 if ( ! $service_id || '' === $operation_id ) {
450 return new WP_Error( 'service_draft_transition_invalid', __( 'The Service Draft transition is invalid.', 'booking' ) );
451 }
452
453 $existing = $this->find( $service_id );
454 if ( is_wp_error( $existing ) ) {
455 return $existing;
456 }
457
458 $metadata = wpbc_appointment_services_decode_metadata( isset( $existing['metadata'] ) ? $existing['metadata'] : array() );
459 $metadata['wpbc_setup_wizard_inactive_operation_id'] = $operation_id;
460 $encoded_metadata = wp_json_encode( $metadata );
461 if ( false === $encoded_metadata ) {
462 return new WP_Error( 'service_metadata_invalid', __( 'The Service metadata could not be saved.', 'booking' ) );
463 }
464
465 $result = $wpdb->update(
466 wpbc_appointment_services_table_name( 'services' ),
467 array(
468 'status' => 'inactive',
469 'metadata' => $encoded_metadata,
470 'modified_by' => get_current_user_id(),
471 'modification_date' => current_time( 'mysql' ),
472 ),
473 array(
474 'service_id' => $service_id,
475 'owner_user_id' => $this->owner_user_id(),
476 'status' => 'active',
477 ),
478 array( '%s', '%s', '%d', '%s' ),
479 array( '%d', '%d', '%s' )
480 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
481
482 if ( false === $result ) {
483 return new WP_Error( 'service_save_failed', __( 'The Service could not be moved to Draft.', 'booking' ) );
484 }
485 if ( 0 === $result ) {
486 return new WP_Error( 'service_draft_transition_stale', __( 'The Service changed before it could be moved to Draft.', 'booking' ) );
487 }
488
489 return $this->find( $service_id );
490 }
491
492 /**
493 * Assign one Booking Form without changing Service details or Providers.
494 *
495 * @param int $service_id Service ID.
496 * @param int $booking_form_id Canonical Booking Form ID, or zero to clear it.
497 *
498 * @return array<string,mixed>|WP_Error Updated Service or persistence error.
499 */
500 public function update_booking_form_id( $service_id, $booking_form_id ) {
501 global $wpdb;
502
503 $service_id = absint( $service_id );
504 $existing = $this->find( $service_id );
505 if ( is_wp_error( $existing ) ) {
506 return $existing;
507 }
508
509 $result = $wpdb->update(
510 wpbc_appointment_services_table_name( 'services' ),
511 array(
512 'booking_form_id' => absint( $booking_form_id ),
513 'modified_by' => get_current_user_id(),
514 'modification_date' => current_time( 'mysql' ),
515 ),
516 array( 'service_id' => $service_id ),
517 array( '%d', '%d', '%s' ),
518 array( '%d' )
519 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
520
521 if ( false === $result ) {
522 return new WP_Error( 'service_save_failed', __( 'The Service could not be saved.', 'booking' ) );
523 }
524
525 return $this->find( $service_id );
526 }
527
528 /**
529 * Restore an authorized Service row without touching Provider assignments.
530 *
531 * This compensation writer accepts only a before-image produced by
532 * {@see get_deletion_before_image()}. It is intentionally narrower than the
533 * deletion restore because the Service must still exist and remain visible to
534 * the current owner at compensation time.
535 *
536 * @param array<string,mixed> $before_image Canonical Service before-image.
537 *
538 * @return true|WP_Error True when the exact Service row was restored.
539 */
540 public function restore_service_row( $before_image ) {
541 global $wpdb;
542
543 $service = isset( $before_image['service'] ) && is_array( $before_image['service'] ) ? $before_image['service'] : array();
544 $service_id = isset( $service['service_id'] ) ? absint( $service['service_id'] ) : 0;
545 if ( ! $service_id || is_wp_error( $this->find( $service_id ) ) ) {
546 return new WP_Error( 'wpbc_service_compensation_failed', __( 'A previous Service value could not be restored.', 'booking' ) );
547 }
548
549 unset( $service['service_id'] );
550 $result = $wpdb->update(
551 wpbc_appointment_services_table_name( 'services' ),
552 $service,
553 array( 'service_id' => $service_id ),
554 null,
555 array( '%d' )
556 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
557
558 if ( false === $result ) {
559 return new WP_Error( 'wpbc_service_compensation_failed', __( 'A previous Service value could not be restored.', 'booking' ) );
560 }
561
562 $restored = $this->get_deletion_before_image( $service_id );
563 if ( is_wp_error( $restored ) || $before_image !== $restored ) {
564 return new WP_Error( 'wpbc_service_compensation_failed', __( 'A previous Service value could not be verified after restoration.', 'booking' ) );
565 }
566
567 return true;
568 }
569
570 /**
571 * Replace every active Provider assignment for a Service.
572 *
573 * @param int $service_id Service ID.
574 * @param int[] $resource_ids Requested Provider resource IDs.
575 *
576 * @return true|WP_Error True on success or an assignment persistence error.
577 */
578 private function replace_resources( $service_id, $resource_ids ) {
579 global $wpdb;
580 $service_id = absint( $service_id );
581 $resource_ids = array_values( array_unique( array_intersect( array_map( 'absint', (array) $resource_ids ), array_keys( wpbc_appointment_services_get_provider_options() ) ) ) );
582 $existing_resources = array();
583 $existing_rows = (array) $wpdb->get_results(
584 $wpdb->prepare(
585 'SELECT resource_id FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE service_id = %d',
586 $service_id
587 ),
588 ARRAY_A
589 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
590 foreach ( $existing_rows as $existing_row ) {
591 $existing_resources[] = absint( $existing_row['resource_id'] );
592 }
593 foreach ( $resource_ids as $priority => $resource_id ) {
594 if ( in_array( $resource_id, $existing_resources, true ) ) {
595 $result = $wpdb->update(
596 wpbc_appointment_services_table_name( 'service_resources' ),
597 array( 'priority' => $priority, 'status' => 'active' ),
598 array( 'service_id' => $service_id, 'resource_id' => $resource_id ),
599 array( '%d', '%s' ),
600 array( '%d', '%d' )
601 );
602 } else {
603 $result = $wpdb->insert(
604 wpbc_appointment_services_table_name( 'service_resources' ),
605 array( 'service_id' => $service_id, 'resource_id' => $resource_id, 'priority' => $priority, 'status' => 'active' ),
606 array( '%d', '%d', '%d', '%s' )
607 );
608 }
609 if ( false === $result ) { return new WP_Error( 'service_assignment_failed', __( 'Provider assignments could not be saved.', 'booking' ) ); }
610 }
611
612 $removed_resources = array_values( array_diff( $existing_resources, $resource_ids ) );
613 if ( $removed_resources ) {
614 $placeholders = implode( ',', array_fill( 0, count( $removed_resources ), '%d' ) );
615 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE service_id = %d AND resource_id IN (' . $placeholders . ')';
616 $result = $wpdb->query( $wpdb->prepare( $sql, array_merge( array( $service_id ), $removed_resources ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
617 if ( false === $result ) { return new WP_Error( 'service_assignment_failed', __( 'Provider assignments could not be saved.', 'booking' ) ); }
618 }
619 return true;
620 }
621
622 /**
623 * Duplicate a Service as an inactive copy.
624 *
625 * @param int $service_id Source Service ID.
626 *
627 * @return array<string,mixed>|WP_Error Duplicated Service or an error.
628 */
629 public function duplicate( $service_id ) {
630 $source = $this->find( $service_id );
631 if ( is_wp_error( $source ) ) { return $source; }
632 $source['service_id'] = 0; $source['title'] = sprintf( __( '%s (Copy)', 'booking' ), $source['title'] ); $source['status'] = 'inactive';
633 return $this->save( $source );
634 }
635
636 /**
637 * Archive one owner-visible Service.
638 *
639 * @param int $service_id Service ID.
640 *
641 * @return bool|WP_Error True on success or an error.
642 */
643 public function archive( $service_id ) {
644 $service = $this->find( $service_id );
645 if ( is_wp_error( $service ) ) { return $service; }
646 $service['status'] = 'archived';
647 return is_wp_error( $this->save( $service ) ) ? new WP_Error( 'service_archive_failed', __( 'The Service could not be archived.', 'booking' ) ) : true;
648 }
649
650 /**
651 * Load the complete owner-authorized before-image required for safe deletion.
652 *
653 * @param int $service_id Service ID.
654 *
655 * @return array<string,mixed>|WP_Error Canonical Service and assignment rows, or an error.
656 */
657 public function get_deletion_before_image( $service_id ) {
658 global $wpdb;
659
660 $service_id = absint( $service_id );
661 $authorized = $this->find( $service_id );
662 if ( is_wp_error( $authorized ) ) {
663 return $authorized;
664 }
665
666 $service = $wpdb->get_row(
667 $wpdb->prepare(
668 'SELECT * FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' WHERE service_id = %d LIMIT 1',
669 $service_id
670 ),
671 ARRAY_A
672 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
673 if ( ! $service ) {
674 return new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) );
675 }
676
677 $assignments = $wpdb->get_results(
678 $wpdb->prepare(
679 'SELECT * FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE service_id = %d ORDER BY assignment_id',
680 $service_id
681 ),
682 ARRAY_A
683 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
684 if ( null === $assignments ) {
685 return new WP_Error( 'appointment_services_storage_unavailable', __( 'Provider assignments could not be audited safely.', 'booking' ) );
686 }
687
688 return array(
689 'service' => $service,
690 'assignments' => $assignments,
691 );
692 }
693
694 /**
695 * Audit every native reference that can make permanent Service deletion unsafe.
696 *
697 * Provider assignments are owned children and are reported for review. Existing
698 * appointment snapshots and saved Appointment shortcode or block configuration
699 * are independent consumers and therefore block deletion.
700 *
701 * @param int $service_id Service ID.
702 *
703 * @return array<string,mixed>|WP_Error Reference counts and confirmed post references, or an error.
704 */
705 public function get_deletion_impact( $service_id ) {
706 $service_id = absint( $service_id );
707 $impacts = $this->get_deletion_impacts( array( $service_id ) );
708
709 if ( is_wp_error( $impacts ) ) {
710 return $impacts;
711 }
712
713 return isset( $impacts[ $service_id ] )
714 ? $impacts[ $service_id ]
715 : new WP_Error( 'service_not_found', __( 'Service not found.', 'booking' ) );
716 }
717
718 /**
719 * Audit deletion references for a bounded Service selection in batch.
720 *
721 * Appointment snapshot counts and saved post candidates are loaded once for
722 * the complete selection. This keeps bulk deletion review independent from
723 * selection size and avoids one full saved-content scan per Service.
724 *
725 * @param array<int,int> $service_ids Service IDs to audit.
726 * @param array<int,array<string,mixed>>|null $before_images Optional authorized before-images keyed by Service ID.
727 *
728 * @return array<int,array<string,mixed>>|WP_Error Impacts keyed by Service ID, or a safe audit error.
729 */
730 public function get_deletion_impacts( $service_ids, $before_images = null ) {
731 global $wpdb;
732
733 $service_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $service_ids ) ) ) );
734 if ( empty( $service_ids ) ) {
735 return new WP_Error( 'wpbc_service_invalid_delete_selection', __( 'Select at least one Service.', 'booking' ) );
736 }
737
738 $before_images = is_array( $before_images ) ? $before_images : array();
739 foreach ( $service_ids as $service_id ) {
740 if ( isset( $before_images[ $service_id ] ) && is_array( $before_images[ $service_id ] ) ) {
741 continue;
742 }
743 $before_image = $this->get_deletion_before_image( $service_id );
744 if ( is_wp_error( $before_image ) ) {
745 return $before_image;
746 }
747 $before_images[ $service_id ] = $before_image;
748 }
749
750 $id_placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%d' ) );
751 $appointment_rows = $wpdb->get_results(
752 $wpdb->prepare(
753 '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",
754 $service_ids
755 ),
756 ARRAY_A
757 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
758 if ( null === $appointment_rows ) {
759 return new WP_Error( 'appointment_services_storage_unavailable', __( 'Appointment references could not be audited safely.', 'booking' ) );
760 }
761
762 $appointment_counts = array_fill_keys( $service_ids, 0 );
763 foreach ( $appointment_rows as $appointment_row ) {
764 $appointment_service_id = absint( $appointment_row['service_id'] );
765 if ( isset( $appointment_counts[ $appointment_service_id ] ) ) {
766 $appointment_counts[ $appointment_service_id ] = absint( $appointment_row['reference_count'] );
767 }
768 }
769
770 $post_candidates = $wpdb->get_results(
771 $wpdb->prepare(
772 "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",
773 '%' . $wpdb->esc_like( 'booking_appointment' ) . '%',
774 'trash',
775 'auto-draft',
776 'inherit'
777 ),
778 ARRAY_A
779 ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
780 if ( null === $post_candidates ) {
781 return new WP_Error( 'appointment_services_storage_unavailable', __( 'Saved page references could not be audited safely.', 'booking' ) );
782 }
783
784 $post_references = array_fill_keys( $service_ids, array() );
785 foreach ( $post_candidates as $post_candidate ) {
786 $authorized_post_reference = array(
787 'id' => absint( $post_candidate['ID'] ),
788 'title' => sanitize_text_field( (string) $post_candidate['post_title'] ),
789 'type' => sanitize_key( (string) $post_candidate['post_type'] ),
790 'status' => sanitize_key( (string) $post_candidate['post_status'] ),
791 );
792 foreach ( $service_ids as $service_id ) {
793 if ( $this->post_content_references_service( (string) $post_candidate['post_content'], $service_id ) ) {
794 $post_references[ $service_id ][] = $authorized_post_reference;
795 }
796 }
797 }
798
799 $impacts = array();
800 foreach ( $service_ids as $service_id ) {
801 $service_post_references = $post_references[ $service_id ];
802 $impacts[ $service_id ] = array(
803 'assignment_count' => count( $before_images[ $service_id ]['assignments'] ),
804 'appointment_count' => $appointment_counts[ $service_id ],
805 'post_reference_count' => count( $service_post_references ),
806 'post_references' => $service_post_references,
807 'is_complete' => true,
808 );
809 }
810
811 return $impacts;
812 }
813
814 /**
815 * Determine whether saved post content explicitly names one Appointment Service.
816 *
817 * @param string $post_content Saved block or shortcode content.
818 * @param int $service_id Service ID.
819 *
820 * @return bool True when an Appointment shortcode or block contains the ID.
821 */
822 private function post_content_references_service( $post_content, $service_id ) {
823 $service_id = absint( $service_id );
824 if ( ! $service_id || '' === $post_content || false === stripos( $post_content, 'booking_appointment' ) ) {
825 return false;
826 }
827
828 $single_patterns = array(
829 '/\bservice_id\s*=\s*["\']?' . $service_id . '(?:["\']|\s|\]|$)/i',
830 '/["\']service_id["\']\s*:\s*["\']?' . $service_id . '(?:["\']|\s|,|\}|$)/i',
831 );
832 foreach ( $single_patterns as $single_pattern ) {
833 if ( preg_match( $single_pattern, $post_content ) ) {
834 return true;
835 }
836 }
837
838 $list_patterns = array(
839 '/\bservice_ids\s*=\s*["\']([^"\']*)["\']/i',
840 '/\bservices\s*=\s*["\']([^"\']*)["\']/i',
841 '/["\']service_ids["\']\s*:\s*\[([^\]]*)\]/i',
842 '/["\']services["\']\s*:\s*(?:\[([^\]]*)\]|["\']([^"\']*)["\'])/i',
843 );
844 foreach ( $list_patterns as $list_pattern ) {
845 if ( ! preg_match_all( $list_pattern, $post_content, $matches ) ) {
846 continue;
847 }
848 $matched_lists = isset( $matches[1] ) ? $matches[1] : array();
849 if ( isset( $matches[2] ) ) {
850 foreach ( $matches[2] as $match_index => $alternate_match ) {
851 if ( '' !== $alternate_match ) {
852 $matched_lists[ $match_index ] = $alternate_match;
853 }
854 }
855 }
856 foreach ( $matched_lists as $matched_list ) {
857 $matched_ids = array_values( array_filter( array_map( 'absint', preg_split( '/[^0-9]+/', (string) $matched_list ) ) ) );
858 if ( in_array( $service_id, $matched_ids, true ) ) {
859 return true;
860 }
861 }
862 }
863
864 return false;
865 }
866
867 /**
868 * Start a native database transaction for a reviewed deletion batch.
869 *
870 * @return bool True when the transaction command succeeded.
871 */
872 public function begin_transaction() {
873 global $wpdb;
874 return false !== $wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
875 }
876
877 /**
878 * Commit the active native database transaction.
879 *
880 * @return bool True when the commit command succeeded.
881 */
882 public function commit_transaction() {
883 global $wpdb;
884 return false !== $wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
885 }
886
887 /**
888 * Roll back the active native database transaction.
889 *
890 * @return bool True when the rollback command succeeded.
891 */
892 public function rollback_transaction() {
893 global $wpdb;
894 return false !== $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
895 }
896
897 /**
898 * Delete one Service only when its reviewed canonical row remains unchanged.
899 *
900 * @param array<string,mixed> $before_image Canonical before-image from get_deletion_before_image().
901 *
902 * @return true|WP_Error True on success, or a stale/storage error.
903 */
904 public function compare_and_delete_service( $before_image ) {
905 global $wpdb;
906
907 $service = isset( $before_image['service'] ) && is_array( $before_image['service'] ) ? $before_image['service'] : array();
908 $service_id = isset( $service['service_id'] ) ? absint( $service['service_id'] ) : 0;
909 if ( ! $service_id ) {
910 return new WP_Error( 'wpbc_service_invalid_delete_before_image', __( 'The reviewed Service deletion is invalid.', 'booking' ) );
911 }
912
913 $assignment_result = $wpdb->delete(
914 wpbc_appointment_services_table_name( 'service_resources' ),
915 array( 'service_id' => $service_id ),
916 array( '%d' )
917 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
918 if ( false === $assignment_result ) {
919 return new WP_Error( 'wpbc_service_delete_failed', __( 'Provider assignments could not be removed.', 'booking' ) );
920 }
921
922 $delete_result = $wpdb->delete(
923 wpbc_appointment_services_table_name( 'services' ),
924 array(
925 'service_id' => $service_id,
926 'owner_user_id' => absint( $service['owner_user_id'] ),
927 'modification_date' => (string) $service['modification_date'],
928 ),
929 array( '%d', '%d', '%s' )
930 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
931 if ( 1 !== $delete_result ) {
932 $this->restore_deleted_service( $before_image );
933 return new WP_Error( 'wpbc_service_stale_delete_apply', __( 'The Service changed after review. Review the deletion again.', 'booking' ) );
934 }
935
936 return true;
937 }
938
939 /**
940 * Restore a deleted Service and its assignments after a partial batch failure.
941 *
942 * @param array<string,mixed> $before_image Canonical deletion before-image.
943 *
944 * @return true|WP_Error True on success, or a compensation error.
945 */
946 public function restore_deleted_service( $before_image ) {
947 global $wpdb;
948
949 $service = isset( $before_image['service'] ) && is_array( $before_image['service'] ) ? $before_image['service'] : array();
950 $service_id = isset( $service['service_id'] ) ? absint( $service['service_id'] ) : 0;
951 if ( ! $service_id ) {
952 return new WP_Error( 'wpbc_service_delete_compensation_failed', __( 'A deleted Service could not be restored.', 'booking' ) );
953 }
954
955 $exists = $wpdb->get_var(
956 $wpdb->prepare(
957 'SELECT service_id FROM ' . wpbc_appointment_services_table_name( 'services' ) . ' WHERE service_id = %d',
958 $service_id
959 )
960 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
961 if ( ! $exists && false === $wpdb->insert( wpbc_appointment_services_table_name( 'services' ), $service ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
962 return new WP_Error( 'wpbc_service_delete_compensation_failed', __( 'A deleted Service could not be restored.', 'booking' ) );
963 }
964
965 $assignments = isset( $before_image['assignments'] ) && is_array( $before_image['assignments'] ) ? $before_image['assignments'] : array();
966 foreach ( $assignments as $assignment ) {
967 $assignment_exists = $wpdb->get_var(
968 $wpdb->prepare(
969 'SELECT assignment_id FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE assignment_id = %d',
970 absint( $assignment['assignment_id'] )
971 )
972 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
973 if ( ! $assignment_exists && false === $wpdb->insert( wpbc_appointment_services_table_name( 'service_resources' ), $assignment ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
974 return new WP_Error( 'wpbc_service_delete_compensation_failed', __( 'Deleted Provider assignments could not be restored.', 'booking' ) );
975 }
976 }
977
978 return true;
979 }
980
981 /**
982 * Find an active Service with an active assignment to a Provider.
983 *
984 * @param int $service_id Service ID.
985 * @param int $resource_id Provider resource ID.
986 *
987 * @return array<string,mixed>|WP_Error Service row or compatibility/storage error.
988 */
989 public function find_active_for_resource( $service_id, $resource_id ) {
990 global $wpdb;
991 if ( ! $this->is_ready() ) { return wpbc_appointment_services_storage_error(); }
992 $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';
993 $service = $wpdb->get_row( $wpdb->prepare( $sql, absint( $service_id ), 'active', absint( $resource_id ), 'active' ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
994
995 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' ) );
996 }
997
998 /**
999 * Insert or update the immutable Service values attached to an Appointment.
1000 *
1001 * @param int $booking_id Core booking ID.
1002 * @param int $resource_id Provider resource ID.
1003 * @param array<string,mixed> $service Service values copied into the snapshot.
1004 *
1005 * @return bool True when the snapshot was persisted.
1006 */
1007 public function save_appointment_snapshot( $booking_id, $resource_id, $service ) {
1008 global $wpdb;
1009 if ( ! $this->is_ready() || ! absint( $booking_id ) || ! absint( $resource_id ) || empty( $service['service_id'] ) ) {
1010 return false;
1011 }
1012 $service = wp_parse_args(
1013 (array) $service,
1014 array(
1015 'title' => '',
1016 'duration_minutes' => 0,
1017 'buffer_before_minutes'=> 0,
1018 'buffer_after_minutes' => 0,
1019 'base_cost' => '0.00',
1020 'booking_form_id' => 0,
1021 )
1022 );
1023 if ( ! wpbc_appointment_services_is_pricing_available() ) {
1024 // New snapshots below Business Small must not imply an unsupported charge.
1025 $service['base_cost'] = '0.00';
1026 $service['base_service_cost'] = '0.00';
1027 $service['cost_override'] = null;
1028 }
1029 $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
1030 if ( $existing_id ) {
1031 return true;
1032 }
1033
1034 $now = current_time( 'mysql' );
1035 $base_service_cost = isset( $service['base_service_cost'] ) && is_numeric( $service['base_service_cost'] )
1036 ? number_format( min( 9999999999.99, max( 0, (float) $service['base_service_cost'] ) ), 2, '.', '' )
1037 : number_format( min( 9999999999.99, max( 0, (float) $service['base_cost'] ) ), 2, '.', '' );
1038 $cost_override = isset( $service['cost_override'] ) && is_numeric( $service['cost_override'] )
1039 ? number_format( min( 9999999999.99, max( 0, (float) $service['cost_override'] ) ), 2, '.', '' )
1040 : null;
1041 $metadata = array(
1042 'schema_version' => 2,
1043 'provider_title' => wpbc_appointment_services_get_provider_title( $resource_id ),
1044 'base_duration_minutes' => isset( $service['base_duration_minutes'] ) ? min( 65535, absint( $service['base_duration_minutes'] ) ) : min( 65535, absint( $service['duration_minutes'] ) ),
1045 'duration_override_minutes' => isset( $service['duration_override_minutes'] ) ? min( 65535, absint( $service['duration_override_minutes'] ) ) : 0,
1046 'base_service_cost' => $base_service_cost,
1047 'cost_override' => $cost_override,
1048 );
1049 $data = array(
1050 'booking_id' => absint( $booking_id ), 'service_id' => absint( $service['service_id'] ), 'resource_id' => absint( $resource_id ),
1051 'service_title' => sanitize_text_field( $service['title'] ), 'duration_minutes' => min( 65535, absint( $service['duration_minutes'] ) ),
1052 'buffer_before_minutes' => min( 65535, absint( $service['buffer_before_minutes'] ) ), 'buffer_after_minutes' => min( 65535, absint( $service['buffer_after_minutes'] ) ),
1053 'base_cost' => number_format( min( 9999999999.99, max( 0, (float) $service['base_cost'] ) ), 2, '.', '' ), 'booking_form_id' => absint( $service['booking_form_id'] ),
1054 'metadata' => wp_json_encode( $metadata ), 'creation_date' => $now, 'modification_date' => $now,
1055 );
1056 $formats = array( '%d', '%d', '%d', '%s', '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s' );
1057 $inserted = $wpdb->insert( wpbc_appointment_services_table_name( 'appointment_details' ), $data, $formats );
1058 if ( false !== $inserted ) {
1059 return true;
1060 }
1061
1062 // A concurrent duplicate hook may have inserted the immutable row first.
1063 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
1064 }
1065
1066 /**
1067 * Return the Appointment Service snapshot for a core booking.
1068 *
1069 * @param int $booking_id Core booking ID.
1070 *
1071 * @return array<string,mixed>|null Snapshot row, or null when absent/unavailable.
1072 */
1073 public function get_appointment_snapshot( $booking_id ) {
1074 global $wpdb;
1075 if ( ! $this->is_ready() ) { return null; }
1076 $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
1077 return $row ? $row : null;
1078 }
1079
1080 /**
1081 * Update the Provider stored in an Appointment snapshot after a valid move.
1082 *
1083 * @param int $booking_id Core booking ID.
1084 * @param int $resource_id New Provider resource ID.
1085 *
1086 * @return bool True when the database update did not fail.
1087 */
1088 public function update_snapshot_resource( $booking_id, $resource_id ) {
1089 global $wpdb;
1090 $snapshot = $this->get_appointment_snapshot( $booking_id );
1091 if ( ! $snapshot ) {
1092 return true;
1093 }
1094 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
1095 $metadata['schema_version'] = max( 2, isset( $metadata['schema_version'] ) ? absint( $metadata['schema_version'] ) : 0 );
1096 $metadata['provider_title'] = wpbc_appointment_services_get_provider_title( $resource_id );
1097
1098 return false !== $wpdb->update(
1099 wpbc_appointment_services_table_name( 'appointment_details' ),
1100 array( 'resource_id' => absint( $resource_id ), 'metadata' => wp_json_encode( $metadata ), 'modification_date' => current_time( 'mysql' ) ),
1101 array( 'booking_id' => absint( $booking_id ) ),
1102 array( '%d', '%s', '%s' ), array( '%d' )
1103 );
1104 }
1105 }
1106