hooks(); } /** * Initiate our hooks. * * @since 6.1.0 */ public function hooks() { add_action( 'ssa/appointment/booked', array( $this, 'insert_revision_booked_appointment' ), 10, 3 ); add_action( 'ssa/appointment/edited', array( $this, 'insert_revision_edited_appointment' ), 10, 3 ); add_action( 'ssa/appointment/rescheduled', array( $this, 'insert_revision_rescheduled_appointment' ), 10, 3 ); add_action( 'ssa/appointment/abandoned', array( $this, 'insert_revision_abandoned_appointment' ), 10, 3 ); add_action( 'ssa/appointment/canceled', array( $this, 'insert_revision_canceled_appointment' ), 10, 3 ); add_action( 'ssa/appointment/pending', array( $this, 'insert_revision_pending_appointment' ), 10, 3 ); // Add revision on appointment no show/reverted add_action( 'ssa/appointment/no_show', array( $this, 'insert_revision_no_show_appointment' ), 10, 3 ); add_action( 'ssa/appointment/no_show_reverted', array( $this, 'insert_revision_no_show_reverted_appointment' ), 10, 3 ); // Add revision first assigned to team member add_action( 'ssa/appointment/after_insert', array( $this, 'maybe_insert_revision_assigned_appointment' ), 1000, 2 ); // Add revision record whenever an appointment is reassigned add_action( 'ssa/appointment/after_update', array( $this, 'maybe_insert_revision_reassigned_appointment' ), 1000, 3 ); // scheduled cleanup add_action( 'init', array( $this, 'schedule_async_actions' ) ); add_action( 'ssa/revisions/cleanup', array( $this, 'cleanup_revisions' ), 10, 0 ); // Notifications add_action( 'ssa/notification/scheduled', array( $this, 'insert_revision_on_notification_scheduled' ), 10, 9 ); add_action( 'ssa/notification/sent', array( $this, 'insert_revision_on_notification_sent' ), 10, 8 ); //Appointment Types revisions add_action( 'ssa/appointment_type/after_insert', array( $this, 'insert_revision_created_appointment_type' ), 1000, 3 ); add_action( 'ssa/appointment_type/after_delete', array( $this, 'insert_revision_deleted_appointment_type' ), 1000, 3 ); add_action( 'ssa/appointment_type/after_update', array( $this, 'insert_revision_updated_appointment_type' ), 1000, 3 ); } /** * Scheduling the revisions cleanup async action * * @return void */ public function schedule_async_actions() { if( ssa_should_skip_async_logic() ) { return; } // below functions wrap the action scheduler methods, make all the needed checks and log any failures if ( false === ssa_has_scheduled_action( 'ssa/revisions/cleanup' ) ) { ssa_schedule_recurring_action( strtotime( 'now' ), DAY_IN_SECONDS, 'ssa/revisions/cleanup' ); } } /** * revisions scheduled cleanup * * @return void */ public function cleanup_revisions() { $revisions = $this->query( array( 'date_created_max' => date( 'Y-m-d H:i:s', strtotime( '-3 months' ) ), ) ); // get ids of revisions as an array $revisions_ids = wp_list_pluck( $revisions, 'id' ); if ( ! empty( $revisions_ids ) ) { // delete all corresponding revision_meta rows $this->plugin->revision_meta_model->bulk_delete( array( 'revision_id' => $revisions_ids, ) ); // delete revisions rows $this->bulk_delete( array( 'id' => $revisions_ids, ) ); } $this->check_orphaned_revisions_meta(); } /** * Check for orphaned revisions meta, that their revision_id doesn't exist and delete them * TODO: Consider removing this function in the future * * @return void */ public function check_orphaned_revisions_meta() { // make sure to query all the revisions entries $revisions = $this->query( array( 'number' => -1, 'orderby' => 'id', 'order' => 'ASC', 'fields' => ['id'] ) ); if ( empty( $revisions ) ) { return; // This should be a new site with no appointments yet. } $revisions = wp_list_pluck( $revisions, 'id', 'id' ); $revision_meta = $this->plugin->revision_meta_model->query( array( 'number' => 50, 'orderby' => 'id', 'order' => 'ASC', ) ); if ( empty( $revision_meta ) ) { return; } $revision_meta = wp_list_pluck( $revision_meta, 'id', 'revision_id' ); $revisons_ids_to_delete = array(); foreach ( $revision_meta as $revison_id => $id ) { if ( ! isset( $revisions[ $revison_id ] ) ) { $revisons_ids_to_delete[] = $revison_id; } } if ( ! empty( $revisons_ids_to_delete ) ) { $this->plugin->revision_meta_model->bulk_delete( array( 'revision_id' => $revisons_ids_to_delete, ) ); } } public function has_many() { return array( // TODO check correct name 'Revision_Meta_Values' => array( 'model' => $this->plugin->revision_meta_model, 'foreign_key' => 'revision_id', ), ); } protected $schema = array( 'result' => array( 'field' => 'result', 'label' => 'Result', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'VARCHAR', // 'success', 'failure', or 'warning' 'mysql_length' => '8', 'mysql_unsigned' => false, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // foreign key 'appointment_id' => array( 'field' => 'appointment_id', 'label' => 'Appointment ID', 'default_value' => 0, 'format' => '%d', 'mysql_type' => 'BIGINT', 'mysql_length' => 20, 'mysql_unsigned' => true, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // foreign key 'appointment_type_id' => array( 'field' => 'appointment_type_id', 'label' => 'Appointment Type ID', 'default_value' => 0, 'format' => '%d', 'mysql_type' => 'BIGINT', 'mysql_length' => 20, 'mysql_unsigned' => true, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // foreign key 'user_id' => array( 'field' => 'user_id', 'label' => 'Customer ID', 'default_value' => 0, 'format' => '%d', 'mysql_type' => 'BIGINT', 'mysql_length' => 20, 'mysql_unsigned' => true, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // foreign key 'staff_id' => array( 'field' => 'staff_id', 'label' => 'Staff ID', 'default_value' => 0, 'format' => '%d', 'mysql_type' => 'BIGINT', 'mysql_length' => 20, 'mysql_unsigned' => true, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // foreign key 'payment_id' => array( 'field' => 'payment_id', 'label' => 'Payment ID', 'default_value' => 0, 'format' => '%d', 'mysql_type' => 'BIGINT', 'mysql_length' => 20, 'mysql_unsigned' => true, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // foreign key 'async_action_id' => array( 'field' => 'async_action_id', 'label' => 'Async Action ID', 'default_value' => 0, 'format' => '%d', 'mysql_type' => 'BIGINT', 'mysql_length' => 20, 'mysql_unsigned' => true, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // allows filtering/looking at what happened in a certain timeframe 'date_created' => array( 'field' => 'date_created', 'label' => 'Date Created', 'default_value' => false, 'format' => '%s', 'mysql_type' => 'DATETIME', 'mysql_length' => '', 'mysql_unsigned' => false, 'mysql_allow_null' => true, 'mysql_extra' => '', 'cache_key' => false, ), // action ( edit, cancel, etc...) 'action' => array( 'field' => 'action', 'label' => 'Action', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'VARCHAR', 'mysql_length' => '32', 'mysql_unsigned' => false, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // action_title ( Appointment Canceled, Appointment Booked, etc...) 'action_title' => array( 'field' => 'action_title', 'label' => 'Action Title', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'TINYTEXT', 'mysql_length' => false, 'mysql_unsigned' => false, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // event summary 'action_summary' => array( 'field' => 'action_summary', 'label' => 'Action Summary', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'TEXT', 'mysql_length' => false, 'mysql_unsigned' => false, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), 'summary_vars' => array( 'field' => 'summary_vars', 'label' => 'Summary Variables', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'TEXT', 'mysql_length' => false, 'mysql_unsigned' => false, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, 'encoder' => 'json_serialize', ), // context ( booking, settings, syncing etc...) 'context' => array( 'field' => 'context', 'label' => 'Context', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'VARCHAR', 'mysql_length' => '32', 'mysql_unsigned' => false, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), // better filtering, also cross context filtering, like web meetings across several contexts (google, zoom,, etc) 'sub_context' => array( 'field' => 'sub_context', 'label' => 'Sub Context', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'VARCHAR', 'mysql_length' => '32', 'mysql_unsigned' => false, 'mysql_allow_null' => false, 'mysql_extra' => '', 'cache_key' => false, ), 'stack_trace' => array( 'field' => 'stack_trace', 'label' => 'Stack Trace', 'default_value' => '', 'format' => '%s', 'mysql_type' => 'TEXT', 'mysql_length' => false, 'mysql_unsigned' => false, 'mysql_allow_null' => true, 'mysql_extra' => '', 'cache_key' => false, ), ); // below fields are indexed to use in filtering, like getting all events of a certain appointment_id public $indexes = array( 'appointment_id' => array( 'appointment_id' ), 'appointment_type_id' => array( 'appointment_type_id' ), 'user_id' => array( 'user_id' ), 'staff_id' => array( 'staff_id' ), 'async_action_id' => array( 'async_action_id' ), 'date_created' => array( 'date_created' ), 'action' => array( 'action' ), 'context' => array( 'context' ), 'sub_context' => array( 'sub_context' ), ); public function get_items_permissions_check( $request ) { if ( current_user_can( 'ssa_manage_others_appointments' ) ) { return true; } if ( current_user_can( 'ssa_manage_appointments' ) ) { return true; } if (current_user_can( 'ssa_manage_appointment_types' ) ) { return true; } if ( true === parent::get_item_permissions_check( $request ) ) { return true; } return false; } // TODO IMPORTANT: if each action only has one foreign id populated, 2 or more of the below where conditions will eliminate every result public function filter_where_conditions( $where, $args ) { global $wpdb; if ( ! empty( $args['appointment_id'] ) ) { if( is_array( $args['appointment_id'] ) ) { $appointment_ids = implode( ',', array_map('intval', $args['appointment_id'] ) ); } else { $appointment_ids = intval( $args['appointment_id'] ); } $where .= " AND `appointment_id` IN( $appointment_ids ) "; } if ( ! empty( $args['appointment_type_id'] ) ) { $where .= $wpdb->prepare( ' AND appointment_type_id=%d', sanitize_text_field( $args['appointment_type_id'] ) ); } if ( ! empty( $args['user_id'] ) ) { $where .= $wpdb->prepare( ' AND user_id=%d', sanitize_text_field( $args['user_id'] ) ); } if ( ! empty( $args['staff_id'] ) ) { $where .= $wpdb->prepare( ' AND staff_id=%d', sanitize_text_field( $args['staff_id'] ) ); } if ( ! empty( $args['async_action_id'] ) ) { $where .= $wpdb->prepare( ' AND async_action_id=%d', sanitize_text_field( $args['async_action_id'] ) ); } if ( ! empty( $args['date_created'] ) ) { $where .= $wpdb->prepare( ' AND date_created=%s', sanitize_text_field( $args['date_created'] ) ); } if ( ! empty( $args['action'] ) ) { $where .= $wpdb->prepare( ' AND action=%s', sanitize_text_field( $args['action'] ) ); } if ( ! empty( $args['context'] ) ) { $where .= $wpdb->prepare( ' AND context=%s', sanitize_text_field( $args['context'] ) ); } if ( ! empty( $args['sub_context'] ) ) { $where .= $wpdb->prepare( ' AND sub_context=%s', sanitize_text_field( $args['sub_context'] ) ); } return $where; } // ================================================================================= // // Section: Revision insertion function definitions // // Pattern: functions restructure the data available then pass it to insert_revision // ================================================================================== /** * When it's first assigned to a team member * * @param integer $appointment_id * @param array $data * @return void */ public function maybe_insert_revision_assigned_appointment( $appointment_id, $data_after, $data_before = null ) { if ( empty( $data_after['staff_ids'] ) || ! empty( $data_before ) ) return; $params = array( 'result' => 'success', 'action' => 'assigned', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before, 'staff_ids' => $data_after['staff_ids'] ); $this->insert_revision_appointment( $params ); } public function maybe_insert_revision_reassigned_appointment( $appointment_id, $data_after, $data_before ) { if ( SSA_Appointment_Model::is_appointment_reassigned( $data_after, $data_before ) ) { $params = array( 'result' => 'success', 'action' => 'reassigned', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before, 'staff_ids' => $data_after['staff_ids'] ); $this->insert_revision_appointment( $params ); } } public function insert_revision_gcal_after_sync( $result, $appointment_id, $action, $action_summary, $calendar_id, $calendar_event_id, $event = null ) { $revision_meta = array( 'calendar_id' => $calendar_id, 'calendar_event_id' => $calendar_event_id, ); if ( ! empty( $event ) ) { $revision_meta['event'] = (array) $event; } // below: hints for WP.org to pick up phrases for translation // __( 'Could not find existing event details for appointment ID {{ appointment_id }}', 'simply-schedule-appointments' ); // __( 'Error while creating event for appointment ID {{ appointment_id }}', 'simply-schedule-appointments' ); // __( 'Deleted group event for appointment ID {{ appointment_id }}', 'simply-schedule-appointments' ); // __( 'Deleted individual event for appointment ID {{ appointment_id }}', 'simply-schedule-appointments' ); // __( 'Exception occured while doing sync for appointment ID {{ appointment_id }}', 'simply-schedule-appointments' ); // __( 'Inserted GCAL event for appointment ID {{ appointment_id }}', 'simply-schedule-appointments' ); // __( 'Updated GCAL event for appointment ID {{ appointment_id }}', 'simply-schedule-appointments' ); $this->insert_revision( array( 'result' => $result, 'appointment_id' => $appointment_id, 'action' => $action, 'action_title' => $this->get_action_title( $action, $result ), 'action_summary' => $action_summary, 'summary_vars' => array(), 'context' => 'gcal', 'sub_context' => 'sync', ), $revision_meta ); } // insert revisions opt out notification public function insert_revision_opt_out_notification( $appointment_id, $data_after ) { $params = array( 'result' => 'success', 'action' => 'opt_out_notification', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => array(), ); $this->insert_revision_appointment( $params ); } public function insert_revision_abandoned_appointment( $appointment_id, $data_after, $data_before = null ) { $params = array( 'result' => 'success', 'action' => 'abandoned', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before ); $this->insert_revision_appointment( $params ); } public function insert_revision_edited_appointment( $appointment_id, $data_after, $data_before = null ) { $params = array( 'result' => 'success', 'action' => 'edited', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before ); $this->insert_revision_appointment( $params ); } public function insert_revision_rescheduled_appointment( $appointment_id, $data_after, $data_before = null ) { // Formatting the appointment date and time $date_format = SSA_Utils::localize_default_date_strings( 'F j, Y' ); $time_format = SSA_Utils::localize_default_date_strings( 'g:i a' ); $business_previous_start_date = $this->plugin->utils->get_datetime_as_local_datetime( $data_before['start_date'] )->format( $date_format ); $business_previous_start_time = $this->plugin->utils->get_datetime_as_local_datetime( $data_before['start_date'] )->format( $time_format ); $business_start_date = $this->plugin->utils->get_datetime_as_local_datetime( $data_after['start_date'] )->format( $date_format ); $business_start_time = $this->plugin->utils->get_datetime_as_local_datetime( $data_after['start_date'] )->format( $time_format ); $params = array( 'result' => 'success', 'action' => 'rescheduled', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before, 'business_previous_start_date' => $business_previous_start_date, 'business_previous_start_time' => $business_previous_start_time, 'business_start_date' => $business_start_date, 'business_start_time' => $business_start_time, ); $this->insert_revision_appointment( $params ); } public function insert_revision_booked_appointment( $appointment_id, $data_after, $data_before = null ) { $action = empty( $data_before ) ? 'first_booked' : 'booked'; $params = array( 'result' => 'success', 'action' => $action, 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before ); $this->insert_revision_appointment( $params ); } public function insert_revision_canceled_appointment( $appointment_id, $data_after, $data_before = null ) { $params = array( 'result' => 'success', 'action' => 'canceled', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before ); $this->insert_revision_appointment( $params ); } public function insert_revision_pending_appointment( $appointment_id, $data_after, $data_before = null ) { $params = array( 'result' => 'success', 'action' => $data_after['status'], // pending_form or pending_payment 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => null ); $this->insert_revision_appointment( $params ); } public function insert_revision_no_show_appointment( $appointment_id, $data_after, $meta_data ) { $params = array( 'result' => 'success', 'action' => 'no_show', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => null ); $this->insert_revision_appointment( $params ); } public function insert_revision_no_show_reverted_appointment( $appointment_id, $data_after, $meta_data ) { $params = array( 'result' => 'success', 'action' => 'no_show_reverted', 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => null ); $this->insert_revision_appointment( $params ); } public function insert_revision_appointment( $params ) { if ( empty( $params['action'] || empty( $this->get_action_title( $params['action'] ) ) ) ) { return; } // below: hints for WP.org to pick up phrases for translation // __( '{{ user }} changed the appointment status to {{ action }}', 'simply-schedule-appointments' ); $revision = array( 'result' => $params['result'], 'appointment_id' => $params['appointment_id'], 'action' => $params['action'], 'action_title' => $this->get_action_title( $params['action'] ), 'action_summary' => $this->get_action_summary( $params['action'] ), 'summary_vars' => array( 'user' => $this->get_user_name(), 'staff' => empty( $params['staff_ids'] ) ? array() : $params['staff_ids'], 'action' => $params['action'], 'action_noun' => empty( $params['action_noun'] ) ? null : $params['action_noun'], 'action_verb' => empty( $params['action_verb'] ) ? null : $params['action_verb'], 'recipient_type'=> isset($params['recipient_type']) ? $params['recipient_type'] : null, 'notification_type'=> isset($params['notification_type']) ? $params['notification_type'] : null, 'notification_date'=> isset($params['notification_date']) ? $params['notification_date'] : null, 'notification_time'=> isset($params['notification_time']) ? $params['notification_time'] : null, 'business_previous_start_date' => isset( $params['business_previous_start_date'] ) ? $params['business_previous_start_date'] : null, 'business_previous_start_time' => isset( $params['business_previous_start_time'] ) ? $params['business_previous_start_time'] : null, 'business_start_date' => isset( $params['business_start_date'] ) ? $params['business_start_date'] : null, 'business_start_time' => isset( $params['business_start_time'] ) ? $params['business_start_time'] : null, 'notification_cancelation_reason' => isset( $params['notification_cancelation_reason'] ) ? $params['notification_cancelation_reason'] : null, 'notification_title' => isset( $params['notification_title'] ) ? $params['notification_title'] : null, ), 'context' => 'booking', ); // in the revision_meta // only set meta_value_before if it has a value. // because if the field is set to null, the db will not insert the record. $revision_meta = array(); // append appointment status changes if ( ! isset( $params['data_after']['status'] ) && isset( $params['data_before']['status'] ) ) { // If status is not set we assume it didn't change $params['data_after']['status'] = $params['data_before']['status']; } $revision_meta['status']['meta_value'] = $params['data_after']['status']; if ( isset( $params['data_before']['status'] ) ) { $revision_meta['status']['meta_value_before'] = $params['data_before']['status']; } // append appointment raw data changes $revision_meta['raw_data']['meta_value'] = $params['data_after']; if ( isset( $params['data_before'] ) ) { $revision_meta['raw_data']['meta_value_before'] = $params['data_before']; } // insert revision $this->insert_revision( $revision, $revision_meta ); } // signarture of function that renders the action summary // $this->plugin->templates->render_template_string('{{ user }} changed the appointment status to {{ action }}',['user'=>'name','action'=>'action']) // ========================================================================= // // main re-usable function - use to insert revisions and revisions meta data // // ========================================================================= public function insert_revision( $revision = array(), $revision_meta = array() ) { if ( ! isset( $revision['result'] ) ) { ssa_debug_log( 'must specify result to create revision', 10 ); ssa_debug_log( print_r( $revision, true ), 10 ); //phpcs:ignore return; } if ( ! isset( $revision['appointment_id'] ) && ! isset( $revision['appointment_type_id'] ) && ! isset( $revision['staff_id'] ) && ! isset( $revision['payment_id'] ) && ! isset( $revision['async_action_id'] ) ) { ssa_debug_log( 'must reference at least one foreign key to create revision', 10 ); ssa_debug_log( print_r( $revision, true ), 10 ); //phpcs:ignore return; } if ( ! isset( $revision['action'] ) ) { ssa_debug_log( "action field must be set to create revision\n", 10 ); ssa_debug_log( print_r( $revision, true ), 10 ); //phpcs:ignore return; } if ( ! isset( $revision['action_summary'] ) ) { ssa_debug_log( "action_summary field must be set to create revision\n", 10 ); ssa_debug_log( print_r( $revision, true ), 10 ); //phpcs:ignore return; } if ( ! isset( $revision['context'] ) ) { ssa_debug_log( "context field must be set to create revision\n", 10 ); ssa_debug_log( print_r( $revision, true ), 10 ); //phpcs:ignore return; } // Call & get the stack trace for each revision before insert $stack_trace = ssa_get_stack_trace(); // merge with default values $revision = wp_parse_args( $revision, array( 'user_id' => get_current_user_id(), 'summary_vars' => array(), 'stack_trace' => $this->parse_stack_trace_before_insert( $stack_trace ) ) ); // pass on an array of meta data to be batch inserted under this revision's id in the meta table if ( ! empty( $revision_meta ) ) { $revision['meta_data'] = $revision_meta; } // insert revision and get its id $revision_id = $this->insert( $revision ); return $revision_id; } public function prepare_item_for_response( $item, $recursive = 0 ) { $item = parent::prepare_item_for_response( $item, $recursive ); if ( $recursive >= 0 ) { $item['action_title'] = __( $item['action_title'], 'simply-schedule-appointments' ); $item['action_summary_populated'] = $this->popuplate_action_summary_for_response( $item ); } return $item; } public function get_action_title( $action, $result = 'success' ) { // Only get/edit action titles here $action_titles = array( 'synced_successfully' => 'Appointment Synced', 'failed_to_sync' => 'Appointment Failed to Sync', 'first_booked' => 'Appointment Booked', 'booked' => 'Appointment Booked', 'canceled' => 'Appointment Canceled', 'rescheduled' => 'Appointment Rescheduled', 'edited' => 'Appointment Edited', 'abandoned' => 'Appointment Abandoned', 'pending_payment' => 'Appointment\'s Payment Pending', 'pending_form' => 'Appointment\'s Form Pending', 'no_show' => 'Marked as No Show', 'no_show_reverted' => 'No Show Reverted', 'assigned' => 'Appointment Assigned', 'reassigned' => 'Appointment Reassigned', 'notification_scheduled' => 'Notification Scheduled', 'notification_with_duration' => 'Notification Scheduled', 'reminder' => 'Notification Scheduled', 'notification_sent' => 'Notification Sent', 'notification_canceled' => 'Notification Canceled', 'notification_not_sent' => 'Notification Not Sent', 'publish' => 'Appointment Type Created', 'delete' => 'Appointment Type Deleted', 'recover' => 'Appointment Type Recovered', 'title_changed' => 'Title Changed', 'min_booking_notice_changed' => 'Notice Required Changed', 'buffer_before_changed' => 'Buffer Before Changed', 'buffer_after_changed' => 'Buffer After Changed', 'max_event_count_changed' => 'Per Day Limit Changed', 'status_changed' => 'Appointment Type Recovered', 'capacity_changed' => 'Capacity Changed', 'capacity_type_changed' => 'Capacity Type Changed', 'timezone_style_changed' => 'Timezone Changed', 'duration_changed' => 'Duration Changed', 'label_id_changed' => 'Label Color changed', 'slug_changed' => 'Slug changed', 'instructions_changed' => 'Instructions changed', 'availability_increment_changed' => 'Availability Start Times Changed', 'availability_type_changed' => 'Availability Type Changed', 'availability_changed'=> 'Availability Schedule Changed', 'web_meetings_changed'=> 'Web Meetings Changed', 'booking_flow_settings_changed'=> 'Booking Flow Changed', 'customer_information_changed'=> 'Customer Information Changed', 'staff_changed' => 'Team Members Changed', 'staff_ids_changed' => 'Team Members Staff Changed', 'staff_capacity_changed' => 'Staff Capacity Changed', 'has_max_capacity_changed' => 'Max Capacity Changed', 'reminder_sent' => 'Notification Sent', 'reminder_not_sent'=>'Notification Not Sent', 'max_booking_notice_changed' => 'Max Booking Notice Changed', 'shared_calendar_event_changed' => 'Shared Calendar Event Changed', 'opt_out_notification' => 'Opt Out Notification', ); // Update the array below whenever needed if ( in_array( $action, array( 'sync_appointment_to_calendar' ) ) ) { return $result === 'success' ? $action_titles['synced_successfully'] : $action_titles['failed_to_sync']; } if ( isset( $action_titles[ $action ] ) ) { return $action_titles[$action]; } // We shouldn't really end up here ssa_debug_log( "Action `$action` does not exist in get_action_title\n", 5 ); return; } public function create_item_permissions_check( $request ) { // only ssa code should interact with this class return false; } /** * Check if a given request has access to update a specific item * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|bool */ public function update_item_permissions_check( $request ) { // only ssa code should interact with this class return false; } /** * Check and return the logged in user name * Otherwise return 'A logged out user' * * @param array $data * @return string */ public function get_user_name() { $current_user = wp_get_current_user(); // We don't have a logged in user if ( empty( $current_user->ID ) ) { return __( 'A logged out user', 'simply-schedule-appointments'); } // First check if who's booking/editing is a staff member if( class_exists( 'SSA_Staff_Model' ) ) { $staff_id = $this->plugin->staff_model->get_staff_id_for_user_id( $current_user->ID ); if( ! empty( $staff_id ) ) { $staff = new SSA_Staff_Object( $staff_id ); return $staff->display_name; } } if( ! empty( $current_user->display_name ) ) { return $current_user->display_name; } if( ! empty( $current_user->user_login ) ) { return $current_user->user_login; } // Just in case if all have failed return __( 'A logged out user', 'simply-schedule-appointments'); } public function popuplate_action_summary_for_response( $item ) { /* translators: If found, between double curly braces {{ Should not be translated }}. Actions: booked, canceled, pending_payment.. */ $action_summary = esc_html__( $item['action_summary'], 'simply-schedule-appointments' ); $summary_vars = $item['summary_vars']; // Regular expression to match placeholders wrapped inside double curly braces $pattern = '/{{\s*(.*?)\s*}}/'; // Check if the action_summary contains placeholders if ( preg_match( $pattern, $action_summary ) ) { // Replace the placeholders with actual values $action_summary = preg_replace_callback($pattern, function($matches) use ($summary_vars) { $placeholder = $matches[1]; // Check if the placeholder corresponds to a valid variable name if ( isset( $summary_vars[ $placeholder ] ) ) { if ( $placeholder === 'action' ) { // Only allow translations for actions return __( $summary_vars[ $placeholder ], 'simply-schedule-appointments' ); } if ( $placeholder === 'staff' ) { $staff_ids = $summary_vars[ $placeholder ]; $staff_names = array(); foreach ( $staff_ids as $staff_id ) { $name = SSA_Staff_Model::get_staff_name_by_id( $staff_id ); array_push($staff_names, $name); } if ( empty( $staff_names ) ) { return __( 'no one', 'simply-schedule-appointments' ); } return implode(", ", $staff_names); } return $summary_vars[ $placeholder ]; } else { // Placeholder does not correspond to a valid variable name return $matches[0]; // return the original placeholder } }, $action_summary ); } return $action_summary; } public function get_action_summary( $action = '' ) { switch ( $action ) { case 'first_booked': return 'Appointment booked by {{ user }}'; case 'assigned': return 'Appointment assigned to {{ staff }}'; case 'reassigned': return 'Appointment reassigned to {{ staff }}'; case 'edited': return 'Appointment edited by {{ user }}'; case 'rescheduled': return 'Appointment rescheduled by {{ user }} from {{ business_previous_start_date }} at {{ business_previous_start_time }} to {{ business_start_date }} at {{ business_start_time }}'; case 'booked': case 'canceled': case 'abandoned': case 'pending_payment': case 'pending_form': return '{{ user }} changed the appointment status to {{ action }}'; case 'no_show': return 'Appointment marked as no show by {{ user }}'; case 'no_show_reverted': return 'No show Status reverted by {{ user }}'; case 'notification_scheduled': return 'The notification was scheduled to inform the {{ recipient_type }} that an {{ action_noun }} has been {{ action_verb }}.'; case 'notification_with_duration': return 'The notification was scheduled to be sent on {{ notification_date }} at {{ notification_time }} to inform the {{ recipient_type }} that an {{ action_noun }} has been {{ action_verb }}.'; case 'reminder': return 'The notification was scheduled to be sent on {{ notification_date }} at {{ notification_time }} to remind the {{ recipient_type }} about the appointment.'; case 'notification_sent': return 'The notification was sent by {{ notification_type }} to inform the {{ recipient_type }} that an {{ action_noun }} has been {{ action_verb }}.'; case 'notification_not_sent': return "The notification by {{ notification_type }}, to inform the {{ recipient_type }} that an {{ action_noun }} has been {{ action_verb }}, could not be sent"; case 'publish': return 'Appointment Type created by {{ user }}'; case 'delete': return 'Appointment Type deleted by {{ user }}'; case 'recover': return 'Appointment Type recovered by {{ user }}'; case 'title_changed': return '{{ user }} changed the appointment type title from {{ old_field }} to {{ new_field }}'; case 'min_booking_notice_changed': return '{{ user }} changed the notice required from {{ old_field }} to {{ new_field }}'; case 'max_booking_notice_changed': return '{{ user }} changed how far in advance customers can book an appointment from {{ old_field }} to {{ new_field }}'; case 'shared_calendar_event_changed': return '{{ user }} changed the shared event settings for google calendar'; case 'buffer_before_changed': return '{{ user }} changed the buffer before from {{ old_field }} to {{ new_field }}'; case 'buffer_after_changed': return '{{ user }} changed the buffer after from {{ old_field }} to {{ new_field }}'; case 'max_event_count_changed': return '{{ user }} changed the maximum number of appointments from {{ old_field }} to {{ new_field }}'; case 'status_changed': return '{{ user }} recovered this appointment type'; case 'capacity_changed': return '{{ user }} changed the capacity from {{ old_field }} to {{ new_field }}'; case 'capacity_type_changed': return '{{ user }} changed the capacity type from {{ old_field }} to {{ new_field }}'; case 'timezone_style_changed': return '{{ user }} changed the timezone from {{ old_field }} to {{ new_field }}'; case 'duration_changed': return '{{ user }} changed the duration form {{ old_field }} to {{ new_field }}'; case 'label_id_changed': return '{{ user }} changed the label color'; case 'slug_changed': return '{{ user }} changed the slug from {{ old_field }} to {{ new_field }}'; case 'instructions_changed': return '{{ user }} changed the instructions to {{ new_field }}'; case 'availability_type_changed': return '{{ user }} changed the availability type from {{ old_field }} to {{ new_field }}'; case 'availability_changed': return '{{ user }} changed the availability schedule'; case 'availability_increment_changed': return '{{ user }} changed the appointment start times from {{ old_field }} to {{ new_field }}'; case 'web_meetings_changed': return '{{ user }} changed the web meetings settings'; case 'booking_flow_settings_changed': return '{{ user }} changed the booking flow settings'; case 'customer_information_changed': return '{{ user }} changed the customer information fields'; case 'staff_changed': return '{{ user }} changed the team member booking rules'; case 'staff_ids_changed': return '{{ user }} changed the team members staff'; case 'staff_capacity_changed': return '{{ user }} changed the team members staff capacity'; case 'has_max_capacity_changed': return '{{ user }} switched the maximum capacity option '; case 'reminder_sent': return 'The notification was sent by {{ notification_type }} to remind the {{ recipient_type }} about the appointment'; case 'reminder_not_sent': return 'The notification by {{ notification_type }} to remind the {{ recipient_type }} about the appointment could not be sent'; case 'opt_out_notification': return 'The user has not opted to receive notifications'; case 'notification_canceled': return 'The "{{ notification_title }}" ({{ notification_type }}) notification to the {{ recipient_type }} was canceled for the following reason: {{ notification_cancelation_reason }}'; default: return '{{ user }} changed the appointment status to {{ action }}'; } } public function parse_stack_trace_before_insert( $string = '' ) { $pattern = '/#(\d+)\s+(.*)/'; preg_match_all( $pattern, $string, $matches, PREG_SET_ORDER ); $output = ''; foreach ( $matches as $match ) { $order = $match[1]; // Skip first two traces since these would always be for [0] -> ssa_get_stack_trace() and [1]-> SSA_Revision_Model->insert_revision if ( $order <= 1 ) { continue; } // Skip traces over 18 level deep if ( $order > 18 ) { break; } $output .= $match[0]."\n"; } return $output; } public function insert_revision_on_notification_sent( $appointment_id, $response, $action_noun, $action_verb, $recipient_type,$notification_type,$data_after, $data_before) { if ($response === true || (is_array($response) && !in_array(false, $response))){ $res = 'success'; if($action_noun == 'appointment_start'){ $action = 'reminder_sent'; } else{ $action = 'notification_sent';} } else{ $res = 'failure'; if($action_noun == 'appointment_start'){ $action = 'reminder_not_sent'; } else{ $action = 'notification_not_sent'; } } // Insert revision for notification sent $params = array( 'result' => $res, 'action' => $action, 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before, 'action_noun' => $action_noun, 'action_verb' => $action_verb, 'recipient_type' => $recipient_type, 'notification_type' => $notification_type ); $this->insert_revision_appointment( $params ); } public function insert_revision_on_notification_canceled( $appointment_id, $data) { $defaults = [ 'data_after' => [], 'data_before' => [], 'recipient_type' => '', 'notification_type' => '', 'notification_title' => '', 'notification_cancelation_reason' => '', ]; $data = array_merge($defaults, $data); $params = array( 'result' => 'success', 'action' => 'notification_canceled', 'appointment_id' => $appointment_id, 'data_after' => $data['data_after'], 'data_before' => $data['data_before'], 'recipient_type' => $data['recipient_type'], 'notification_type' => $data['notification_type'], 'notification_title'=> $data['notification_title'], 'notification_cancelation_reason'=> $data['notification_cancelation_reason'], ); $this->insert_revision_appointment( $params ); } public function insert_revision_on_notification_scheduled( $appointment_id, $action_noun, $action_verb, $notification_date, $notification_time, $duration, $recipient_type, $data_after, $data_before) { if($duration > 0){ if($action_noun == 'appointment_start'){ $action = 'reminder'; }else{ $action = 'notification_with_duration'; } }else{ if($action_noun == 'appointment_start'){ return ; }else{ $action = 'notification_scheduled'; } } // Insert revision for notification scheduled $params = array( 'result' => 'success', 'action' => $action, 'appointment_id' => $appointment_id, 'data_after' => $data_after, 'data_before' => $data_before, 'action_noun' => $action_noun, 'action_verb' => $action_verb, 'recipient_type' => $recipient_type, 'notification_date' => $notification_date, 'notification_time' => $notification_time, ); $this->insert_revision_appointment( $params ); $username = $this->get_user_name(); } public function insert_revision_appointment_type( $params ) { if( empty( $params['action'] ) || empty( $this->get_action_title( $params['action'] ) ) ) { return; // This is not a supported action } $revision = array( 'result' => $params['result'], 'appointment_type_id' => $params['appointment_type_id'], 'action' => $params['action'], 'action_title' => $this->get_action_title( $params['action'] ), 'action_summary' => $this->get_action_summary( $params['action'] ), 'summary_vars' => array( 'user' => $this->get_user_name(), 'old_field' => $params['old_field'], 'new_field' => $params['new_field'] ), 'context' => 'appointment type', ); // in the revision_meta // only set meta_value_before if it has a value. // because if the field is set to null, the db will not insert the record. $revision_meta = array(); // append appointment status changes if ( ! isset( $params['data_after']['status'] ) && isset( $params['data_before']['status'] ) ) { // If status is not set we assume it didn't change $params['data_after']['status'] = $params['data_before']['status']; } $revision_meta['status']['meta_value'] = $params['data_after']['status']; if ( isset( $params['data_before']['status'] ) ) { $revision_meta['status']['meta_value_before'] = $params['data_before']['status']; } // append appointment raw data changes $revision_meta['raw_data']['meta_value'] = $params['data_after']; if ( isset( $params['data_before'] ) ) { $revision_meta['raw_data']['meta_value_before'] = $params['data_before']; } // insert revision $this->insert_revision( $revision, $revision_meta ); } public function insert_revision_created_appointment_type( $appointment_type_id, $data_after, $data_before = null) { if( gettype($data_after) !== 'array' ){ $data_after = array(); } if( gettype($data_before) !== 'array' ){ $data_before = array(); } $data_after['status'] = 'publish'; $params = array( 'result' => 'success', 'action' => 'publish', 'appointment_type_id' => $appointment_type_id, 'old_field' => '', 'new_field' => 'published', 'data_after' => $data_after, 'data_before' => $data_before ); $this->insert_revision_appointment_type( $params ); } public function insert_revision_deleted_appointment_type( $appointment_type_id, $data_after, $data_before) { if( gettype($data_after) !== 'array' ){ $data_after = array(); } if( gettype($data_before) !== 'array' ){ $data_before = array(); } $data_after['status'] = 'delete'; $params = array( 'result' => 'success', 'action' => 'delete', 'appointment_type_id' => $appointment_type_id, 'old_field' => 'published', 'new_field' => 'deleted', 'data_after' => $data_after, 'data_before' => $data_before ); $this->insert_revision_appointment_type( $params ); } public function insert_revision_updated_appointment_type( $appointment_type_id, $data_after, $data_before ) { if(empty($data_after) || empty($data_before)){ return; } $changed_fields = $this->get_appt_type_changed_fields($data_after,$data_before); foreach($changed_fields as $changed_field){ if ( empty( $data_after[ $changed_field ] ) || empty( $data_before[ $changed_field ] ) ) { continue; } $old_field = $data_before[$changed_field]; $new_field = $data_after[$changed_field]; if($changed_field !== 'capacity' && $changed_field !== 'max_event_count'){ if (is_numeric($old_field) && is_int($old_field + 0)) { $old_field = $this->display_duration($old_field); } if (is_numeric($new_field) && is_int($new_field + 0)) { $new_field = $this->display_duration($new_field); } } $params = array( 'result' => 'success', 'action' => $changed_field.'_changed', 'appointment_type_id' => $appointment_type_id, 'old_field' => $old_field , 'new_field' => $new_field, 'data_after' => $data_after, 'data_before' => $data_before ); $this->insert_revision_appointment_type( $params ); } } public function get_appt_type_changed_fields($data_after,$data_before){ $changed_fields =[]; foreach( $data_after as $key => $field ) { if ( empty( $data_before[ $key ] ) ) { continue; } // No Need to record the re-ordering of appointment types if ( $key === 'display_order'){ continue; } if( $data_before [ $key ] !== $field ) { if ( $key === 'custom_customer_information') { $key = 'customer_information'; } $changed_fields[] = $key; } } return $changed_fields; } //convert minutes to hours / days / weeks public function display_duration($duration) { $minutes = intval($duration); if ($minutes === 0) { return '0'; } $type = 'minute'; $num = $minutes; if ($minutes % (7 * 24 * 60) === 0) { $num = $minutes / (7 * 24 * 60); $type = 'week'; } elseif ($minutes % (24 * 60) === 0) { $num = $minutes / (24 * 60); $type = 'day'; } elseif ($minutes % 60 === 0) { $num = $minutes / 60; $type = 'hour'; } $displayType = $num === 1 ? $type : $type . 's'; return $num . ' ' . $displayType; } }