'abece_booking_online_booking_resources_v1', 'asb_privacy_retention' => 'abece_booking_privacy_retention', 'asb_privacy_reviews' => 'abece_booking_privacy_reviews', 'asb_privacy_cleanup_audit' => 'abece_booking_privacy_cleanup_audit', 'asb_privacy_retention_cursor' => 'abece_booking_privacy_retention_cursor', 'asb_db_version' => 'abece_booking_db_version', 'asb_capabilities_version' => 'abece_booking_capabilities_version', 'asb_club_location' => 'abece_booking_club_location', 'asb_require_phone' => 'abece_booking_require_phone', 'asb_public_calendar_visibility' => 'abece_booking_public_calendar_visibility', 'asb_enable_credit' => 'abece_booking_enable_credit', 'asb_enable_pay_on_site' => 'abece_booking_enable_pay_on_site', 'asb_enable_bank_transfer' => 'abece_booking_enable_bank_transfer', 'asb_enable_qr_payment' => 'abece_booking_enable_qr_payment', 'asb_bank_account' => 'abece_booking_bank_account', 'asb_bank_payment_profiles' => 'abece_booking_bank_payment_profiles', 'asb_bank_profiles_version' => 'abece_booking_bank_profiles_version', 'asb_block_booking_with_outstanding_balance' => 'abece_booking_block_booking_with_outstanding_balance', 'asb_outstanding_balance_grace_hours' => 'abece_booking_outstanding_balance_grace_hours', 'asb_self_link_enabled' => 'abece_booking_self_link_enabled', 'asb_self_link_method' => 'abece_booking_self_link_method', 'asb_memberships_enabled' => 'abece_booking_memberships_enabled', 'asb_reservation_page_id' => 'abece_booking_reservation_page_id', 'asb_max_daily_customer_bookings' => 'abece_booking_max_daily_customer_bookings', 'asb_online_booking_cutoff_mode' => 'abece_booking_online_booking_cutoff_mode', 'asb_online_booking_cutoff_hours' => 'abece_booking_online_booking_cutoff_hours', 'asb_booking_contact_phone' => 'abece_booking_booking_contact_phone', 'asb_confirmation_email_intro' => 'abece_booking_confirmation_email_intro', 'asb_confirmation_email_outro' => 'abece_booking_confirmation_email_outro', 'asb_registration_privacy_acknowledgement' => 'abece_booking_registration_privacy_acknowledgement', ); public const USER_META = array( 'asb_phone' => 'abece_booking_phone', 'asb_onboarding_dismissed' => 'abece_booking_onboarding_dismissed', 'asb_privacy_acknowledgement' => 'abece_booking_privacy_acknowledgement', 'asb_privacy_request_rate' => 'abece_booking_privacy_request_rate', 'asb_membership_link_audit' => 'abece_booking_membership_link_audit', 'asb_membership_batch_audit' => 'abece_booking_membership_batch_audit', ); public const POST_META = array( 'asb_privacy_export_plan' => 'abece_booking_privacy_export_plan', 'asb_privacy_identity' => 'abece_booking_privacy_identity', 'asb_privacy_review_required' => 'abece_booking_privacy_review_required', 'asb_privacy_retained' => 'abece_booking_privacy_retained', 'asb_privacy_request_owner' => 'abece_booking_privacy_request_owner', 'asb_privacy_mail_failed' => 'abece_booking_privacy_mail_failed', ); public const CAPS = array( 'asb_manage_resources' => 'abece_booking_manage_resources', 'asb_manage_members' => 'abece_booking_manage_members', 'asb_manage_bookings' => 'abece_booking_manage_bookings', 'asb_manage_prices' => 'abece_booking_manage_prices', 'asb_manage_credit' => 'abece_booking_manage_credit', 'asb_manage_cancellation_policies' => 'abece_booking_manage_cancellation_policies', 'asb_override_cancellation_fees' => 'abece_booking_override_cancellation_fees', ); public const CRON = array( 'asb_privacy_retention' => 'abece_booking_privacy_retention', 'asb_privacy_plan_expire' => 'abece_booking_privacy_plan_expire', 'asb_member_import_expire' => 'abece_booking_member_import_expire', ); public const TRANSIENTS = array( 'asb_privacy_export_' => 'abece_booking_privacy_export_', 'asb_privacy_erase_' => 'abece_booking_privacy_erase_', 'asb_geo_' => 'abece_booking_geo_', 'asb_geocoding_last_request' => 'abece_booking_geocoding_last_request', 'asb_member_import_' => 'abece_booking_member_import_', 'asb_link_rate_' => 'abece_booking_link_rate_', 'asb_test_email_' => 'abece_booking_test_email_', ); public const REQUEST_DATA = array( 'asb_self_service' => 'abece_booking_self_service', 'asb_account_closure_requested' => 'abece_booking_account_closure_requested', ); /** * Complete the resumable identifier upgrade before defaults or API registration. * * @return void */ public static function migrate(): void { if ( (int) get_option( self::MARKER, 0 ) >= 1 ) { return; } global $wpdb; $lock = 'abece_booking_identifiers_' . substr( hash( 'sha256', DB_NAME . ':' . $wpdb->prefix ), 0, 32 ); $previous_errors = $wpdb->suppress_errors( true ); $locked = false; try { if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 5)', $lock ) ) !== 1 ) { self::fail(); } $locked = true; self::options(); self::capabilities(); self::metadata( 'user', self::USER_META ); self::metadata( 'post', self::POST_META ); self::request_data(); self::cron(); self::transients(); update_option( self::MARKER, 1, false ); if ( (int) get_option( self::MARKER ) !== 1 ) { self::fail(); } } finally { if ( $locked ) { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } // Option/meta values may contain personal or financial details. Keep // failed prepared SQL out of debug logs, then restore the caller's state. $wpdb->suppress_errors( $previous_errors ); } } /** * Copy only missing options, preserving raw values and autoload metadata. * * @return void */ private static function options(): void { global $wpdb; foreach ( self::OPTIONS as $old => $new ) { // Raw SQL preserves serialized bytes, false/null, and WP 6.6+ autoload states exactly. $source = $wpdb->get_row( $wpdb->prepare( 'SELECT option_value,autoload FROM %i WHERE option_name=%s', $wpdb->options, $old ), ARRAY_A ); if ( $wpdb->last_error ) { self::fail(); } if ( $source === null ) { continue; } $existing = $wpdb->get_var( $wpdb->prepare( 'SELECT option_id FROM %i WHERE option_name=%s', $wpdb->options, $new ) ); if ( $wpdb->last_error ) { self::fail(); } if ( $existing !== null ) { continue; // New configuration, including false/null/empty, wins. } // Use the exact raw source already read above. A self-referencing // INSERT ... SELECT would require a second table alias under LOCK TABLES. $inserted = $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (option_name,option_value,autoload) VALUES (%s,%s,%s)', $wpdb->options, $new, $source['option_value'], $source['autoload'] ) ); if ( $inserted === false ) { self::fail(); } self::option_cache( $new ); $copied = $wpdb->get_row( $wpdb->prepare( 'SELECT option_value,autoload FROM %i WHERE option_name=%s', $wpdb->options, $new ), ARRAY_A ); if ( $wpdb->last_error || $copied === null || ( $inserted === 1 && $copied !== $source ) ) { self::fail(); } // A concurrent new writer is authoritative; INSERT IGNORE never replaces its value. // Old values remain available for inspection/rollback and are not read by runtime defaults. } } /** * Invalidate option caches after a verified raw insert. * * @param string $key Option name. * @return void */ private static function option_cache( string $key ): void { wp_cache_delete( $key, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); } /** * Keep grants, denials and unrelated role or user capabilities. * * @return void */ private static function capabilities(): void { global $wpdb; $key = $wpdb->prefix . 'user_roles'; $roles = get_option( $key, array() ); $updated = $roles; foreach ( $updated as &$role ) { foreach ( self::CAPS as $old => $new ) { if ( array_key_exists( $old, $role['capabilities'] ) && ! array_key_exists( $new, $role['capabilities'] ) ) { $role['capabilities'][ $new ] = $role['capabilities'][ $old ]; } } } unset( $role ); if ( $roles !== $updated ) { update_option( $key, $updated ); if ( get_option( $key ) !== $updated ) { self::fail(); } wp_roles()->for_site(); } $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT umeta_id,user_id,meta_value FROM %i WHERE meta_key=%s', $wpdb->usermeta, $wpdb->prefix . 'capabilities' ), ARRAY_A ); if ( $wpdb->last_error ) { self::fail(); } foreach ( $rows as $row ) { $caps = maybe_unserialize( $row['meta_value'] ); if ( ! is_array( $caps ) ) { continue; } $next = $caps; foreach ( self::CAPS as $old => $new ) { if ( array_key_exists( $old, $caps ) && ! array_key_exists( $new, $caps ) ) { $next[ $new ] = $caps[ $old ]; } } if ( $next === $caps ) { continue; } if ( false === $wpdb->update( $wpdb->usermeta, array( 'meta_value' => maybe_serialize( $next ) ), array( 'umeta_id' => (int) $row['umeta_id'] ) ) ) { self::fail(); } wp_cache_delete( (int) $row['user_id'], 'user_meta' ); } // The SDK or another plugin may already have loaded this request's WP_User object. if ( isset( $GLOBALS['current_user'] ) && $GLOBALS['current_user'] instanceof \WP_User ) { $GLOBALS['current_user']->for_site(); } } /** * Rename metadata in place without decoding values or losing repeated audit rows. * * @param string $type User or post metadata. * @param array $map Explicit legacy-to-current identifiers. * @return void */ private static function metadata( string $type, array $map ): void { global $wpdb; $table = $type === 'user' ? $wpdb->usermeta : $wpdb->postmeta; $owner = $type === 'user' ? 'user_id' : 'post_id'; foreach ( $map as $old => $new ) { $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT %i FROM %i WHERE meta_key=%s', $owner, $table, $old ) ); if ( $wpdb->last_error ) { self::fail(); } foreach ( $ids as $id ) { // Rename all repeated audit rows together without decoding or changing their values. // An existing new key is authoritative. Preserve its legacy shadow for GDPR review. if ( metadata_exists( $type, (int) $id, $new ) ) { continue; } if ( false === $wpdb->query( $wpdb->prepare( 'UPDATE %i SET meta_key=%s WHERE %i=%d AND meta_key=%s', $table, $new, $owner, (int) $id, $old ) ) ) { self::fail(); } wp_cache_delete( (int) $id, $type . '_meta' ); } } } /** * Rename only owned keys in native privacy-request JSON. * * @return void */ private static function request_data(): void { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT ID,post_content FROM %i WHERE post_type=%s', $wpdb->posts, 'user_request' ), ARRAY_A ); if ( $wpdb->last_error ) { self::fail(); } foreach ( $rows as $row ) { $data = json_decode( $row['post_content'], true ); if ( ! is_array( $data ) ) { continue; } $changed = false; foreach ( self::REQUEST_DATA as $old => $new ) { if ( array_key_exists( $old, $data ) ) { if ( ! array_key_exists( $new, $data ) ) { $data[ $new ] = $data[ $old ]; } unset( $data[ $old ] ); $changed = true; } } if ( $changed ) { if ( false === $wpdb->update( $wpdb->posts, array( 'post_content' => wp_json_encode( $data ) ), array( 'ID' => (int) $row['ID'] ) ) ) { self::fail(); } clean_post_cache( (int) $row['ID'] ); } } } /** * Move owned events with their timestamps, arguments and recurrence. * * @return void */ private static function cron(): void { foreach ( _get_cron_array() ?: array() as $timestamp => $hooks ) { foreach ( self::CRON as $old => $new ) { foreach ( $hooks[ $old ] ?? array() as $event ) { $args = $event['args']; // Existing exact new events win; a retry cannot create a second event. if ( ! wp_get_scheduled_event( $new, $args, (int) $timestamp ) ) { $result = $event['schedule'] ? wp_schedule_event( (int) $timestamp, $event['schedule'], $new, $args, true ) : wp_schedule_single_event( (int) $timestamp, $new, $args, true ); if ( is_wp_error( $result ) || ! $result ) { self::fail(); } } $result = wp_unschedule_event( (int) $timestamp, $old, $args, true ); if ( is_wp_error( $result ) || ! $result ) { self::fail(); } } } } } /** * Invalidate temporary legacy plans without renewing their expiry. * * @return void */ private static function transients(): void { global $wpdb; // Temporary import/export plans are invalidated, never revived with a fresh lifetime. foreach ( self::TRANSIENTS as $old => $new ) { $names = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( '_transient_' . $old ) . '%' ) ); if ( $wpdb->last_error ) { self::fail(); } foreach ( $names as $name ) { delete_transient( substr( $name, strlen( '_transient_' ) ) ); } } foreach ( _get_cron_array() ?: array() as $hooks ) { foreach ( $hooks['abece_booking_member_import_expire'] ?? array() as $event ) { $key = $event['args'][0] ?? null; if ( is_string( $key ) && str_starts_with( $key, 'asb_member_import_' ) ) { delete_transient( $key ); } } } } /** * Include preserved pre-upgrade shadow metadata in GDPR handling. * * @param int $user WordPress user ID. * @param string $key Current metadata identifier. * @return array */ public static function user_metadata( int $user, string $key ): array { $values = get_user_meta( $user, $key, false ); $old = array_search( $key, self::USER_META, true ); return $old === false ? $values : array_merge( $values, get_user_meta( $user, $old, false ) ); } /** * Erase current and preserved legacy metadata in the same authorized privacy action. * * @param int $user WordPress user ID. * @param string $key Current metadata identifier. * @return bool */ public static function delete_user_metadata( int $user, string $key ): bool { $removed = delete_user_meta( $user, $key ); $old = array_search( $key, self::USER_META, true ); return ( $old !== false && delete_user_meta( $user, $old ) ) || $removed; } /** * Abort initialization without exposing database details or credentials. * * @return never */ public static function fail(): never { throw new \RuntimeException( __( 'ABeCe Booking could not complete the settings upgrade. Existing data was retained. Please try again.', 'abece-booking' ) ); } }