/home/edulekha/crm.edulekha.com/modules/appointly/controllers/Appointments.php
<?php
defined('BASEPATH') or exit('No direct script access allowed');
class Appointments extends AdminController
{
private bool $staff_no_view_permissions;
private bool $staff_no_edit_permissions;
public function __construct()
{
parent::__construct();
$this->load->helper('appointly');
$this->staff_no_view_permissions = ! staff_can('view', 'appointments');
$this->staff_no_edit_permissions = ! staff_can('edit', 'appointments');
$this->load->model('appointly/appointly_model', 'apm');
if (!$this->db->where('module_name', 'appointly')->get(db_prefix() . 'modules')->row()->active) {
redirect('/');
}
}
/**
* Main view
*
* @return void
*/
public function index()
{
if ($this->staff_no_view_permissions) {
access_denied('Appointments');
}
// Get today's appointments
$data['todays_appointments'] = $this->getTodaysAppointments();
// Get pending cancellation requests
$data['pending_cancellations'] = $this->apm->get_pending_cancellations();
// Get pending reschedule requests
$data['pending_reschedules'] = $this->apm->get_pending_reschedules();
$this->load->view('index', $data);
}
/**
* Get today's appointments
*
* @return array
*/
public function getTodaysAppointments()
{
return $this->apm->fetch_todays_appointments();
}
/**
* Standalone create appointment page (no modal)
*
* @return void
*/
public function create_page()
{
if ($this->staff_no_view_permissions) {
access_denied('Appointments');
}
$data['staff_members'] = $this->staff_model->get('', ['active' => 1]);
$data['contacts'] = appointly_get_staff_customers();
$data['services'] = $this->apm->get_services();
// Get URL parameters (used when creating from client/lead profiles)
$data['client_id'] = $this->input->get('client_id');
$data['contact_id'] = $this->input->get('contact_id');
$data['rel_type'] = $this->input->get('rel_type') ?: ($data['client_id'] ? 'internal' : '');
$data['rel_id'] = $this->input->get('rel_id');
// Special handling for client_id parameter (client profile tab)
if ($data['client_id']) {
$this->load->model('clients_model');
$data['client_contacts'] = $this->clients_model->get_contacts($data['client_id']);
// Pre-select "internal" in the rel_type dropdown
$data['rel_type'] = 'internal';
// If contact_id is provided, verify it belongs to this client
if ($data['contact_id']) {
$contact_belongs_to_client = false;
foreach ($data['client_contacts'] as $contact) {
if ($contact['id'] == $data['contact_id']) {
$contact_belongs_to_client = true;
break;
}
}
if (!$contact_belongs_to_client) {
// If contact doesn't belong to client, unset it
unset($data['contact_id']);
}
}
}
// Special handling for lead-related appointments
else if ($data['rel_type'] === 'lead_related' && $data['rel_id']) {
$this->load->model('leads_model');
$lead = $this->leads_model->get($data['rel_id']);
if ($lead) {
$data['lead_data'] = $lead;
}
}
$data['title'] = _l('appointment_new_appointment');
$this->load->view('pages/create', $data);
}
/**
* Standalone update appointment page (no modal)
*
* @param int $appointment_id
*
* @return void
*/
public function update_page($appointment_id)
{
if ($this->staff_no_edit_permissions) {
access_denied('Appointments');
}
if (! $appointment_id) {
redirect(admin_url('appointly/appointments'));
}
$data['appointment'] = $this->apm->get_appointment_data($appointment_id);
if (! $data['appointment']) {
redirect(admin_url('appointly/appointments'));
}
$data['staff_members'] = $this->staff_model->get('', ['active' => 1]);
$data['contacts'] = appointly_get_staff_customers();
$data['services'] = $this->apm->get_services();
// Use the appointment data we already have
$appointment = $data['appointment'];
// Get attendees IDs array if not already included
if (! isset($appointment['attendees'])) {
$this->load->model('appointly/appointly_attendees_model', 'atm');
$appointment['attendees'] = $this->atm->attendees($appointment_id);
}
// Convert attendees from full objects to just IDs for the form
if (isset($appointment['attendees']) && is_array($appointment['attendees'])) {
$attendee_ids = [];
foreach ($appointment['attendees'] as $attendee) {
if (is_array($attendee) && isset($attendee['staffid'])) {
$attendee_ids[] = $attendee['staffid'];
} elseif (is_string($attendee) || is_numeric($attendee)) {
$attendee_ids[] = $attendee;
}
}
$appointment['attendees'] = $attendee_ids;
}
// Special handling for lead-related appointments
$lead_id = null;
if ($appointment['source'] == 'lead_related' && isset($appointment['contact_id'])) {
// For lead-related appointments, we store lead ID in contact_id field
$lead_id = $appointment['contact_id'];
$appointment['rel_id'] = $lead_id;
// Make sure lead data is loaded
$this->load->model('leads_model');
$lead = $this->leads_model->get($lead_id);
if ($lead) {
// Set lead data to make sure form fields are populated
$appointment['lead_data'] = $lead;
}
}
// Pass lead_id to the view
$data['lead_id'] = $lead_id;
// Get providers for the selected service
$data['service_providers'] = [];
if (! empty($appointment['service_id'])) {
$this->db->select('s.staffid, s.firstname, s.lastname, ss.is_primary');
$this->db->from(db_prefix() . 'staff s');
$this->db->join(
db_prefix() . 'appointly_service_staff ss',
's.staffid = ss.staff_id',
'inner'
);
$this->db->where('ss.service_id', $appointment['service_id']);
$this->db->where('s.active', 1);
$providers = $this->db->get()->result_array();
// Format providers for consistent rendering
foreach ($providers as $provider) {
$data['service_providers'][] = [
'staffid' => $provider['staffid'],
'firstname' => $provider['firstname'],
'lastname' => $provider['lastname'],
'is_primary' => $provider['is_primary'],
];
}
}
// Get full attendee details if needed
$this->load->model('appointly/appointly_attendees_model', 'atm');
$attendees = $this->atm->get($appointment_id);
// Set the appointment data
$data['appointment'] = $appointment;
// Get responsible person (creator) if available
if (! empty($data['appointment']['created_by'])) {
$data['appointment_organizer'] = $this->staff_model->get($data['appointment']['created_by']);
} else {
$data['appointment_organizer'] = null;
}
// Who gets mails
$data['user_email_notifications'] = isset($data['appointment']['email_notification']) && $data['appointment']['email_notification'] ? explode(
',',
$data['appointment']['email_notification']
) : [];
$data['google_calendar_link'] = $data['appointment']['google_calendar_link'] ?? '';
$data['title'] = _l('appointment_edit_appointment');
$data['appointment'] = $appointment;
$data['history'] = array_merge($appointment, [
'selected_staff' => $appointment['attendees'],
'attendees' => $attendees,
'created_by' => $appointment['created_by'],
]);
$this->load->view('pages/update', $data);
}
/**
* Single appointment view
*
* @return void
*/
public function view($id = null)
{
if ($this->staff_no_view_permissions) {
access_denied('Appointments');
}
$this->session->unset_userdata('from_view_id');
$appointment_id = $this->input->get('appointment_id');
$check_outlook = $this->input->get('check_outlook');
$appointment = $this->apm->get_appointment_data($appointment_id);
// Redirect to appointments page if appointment not found
if (!$appointment) {
redirect(admin_url('appointly/appointments'));
}
// If this is just a check for Outlook integration, return JSON
if ($check_outlook) {
if ($appointment) {
header('Content-Type: application/json');
echo json_encode([
'outlook_event_id' => $appointment['outlook_event_id'] ?? null,
'outlook_added_by_id' => $appointment['outlook_added_by_id'] ?? null,
]);
return;
}
}
// Regular view logic continues...
$attendees = $this->atm->attendees($appointment_id);
$google_meet_attendees = [];
// Get staff emails for attendees
if (! empty($attendees) && is_array($attendees)) {
foreach ($attendees as $staff_id) {
$staff = $this->staff_model->get($staff_id);
if ($staff && is_staff_logged_in()) {
// if logged in user email is same as staff email then skip
if ($staff->email == appointly_get_staff(get_staff_user_id())['email']) {
continue;
}
$google_meet_attendees[] = $staff->email;
}
}
}
/**
* All users must have at least view permissions to access appointments
* Attendees get additional access only if they have base permissions
*/
if (! staff_can('view', 'appointments')) {
// Check if user is the appointment creator
if ($appointment['created_by'] != get_staff_user_id()) {
// Check if user is the assigned provider
if (empty($appointment['provider_id']) || $appointment['provider_id'] != get_staff_user_id()) {
// Check if user is an attendee - but still require base view permissions
access_denied('Appointments');
}
}
}
$data['appointment'] = fetch_appointment_data($appointment_id);
if ($data['appointment']) {
// Original URL format
$data['appointment']['public_url'] = appointly_get_appointment_url($data['appointment']['hash']);
$data['appointment']['formatted_date'] = _d($data['appointment']['date']);
if ($data['appointment']['source'] !== 'internal_staff') {
// For service-based appointments, use service duration
if (! empty($data['appointment']['service_id'])) {
if (empty($data['appointment']['start_hour']) && ! empty($data['appointment']['end_hour'])) {
// Calculate start_hour based on end_hour and service duration
$service_duration = ! empty($data['appointment']['service_duration'])
? $data['appointment']['service_duration']
: 0;
if ($service_duration > 0) {
$end_time = strtotime($data['appointment']['end_hour']);
$start_time = $end_time - ($service_duration * 60);
$data['appointment']['start_hour'] = date('H:i', $start_time);
// Update the database for future requests
$this->db->where('id', $appointment_id);
$this->db->update(db_prefix() . 'appointly_appointments', [
'start_hour' => $data['appointment']['start_hour'],
]);
}
} elseif (! empty($data['appointment']['start_hour']) && empty($data['appointment']['end_hour'])) {
// Calculate end_hour based on start_hour and service duration
$service_duration = ! empty($data['appointment']['service_duration'])
? $data['appointment']['service_duration']
: 0;
if ($service_duration > 0) {
$start_time = strtotime($data['appointment']['start_hour']);
$end_time = $start_time + ($service_duration * 60);
$data['appointment']['end_hour'] = date('H:i', $end_time);
// Update the database for future requests
$this->db->where('id', $appointment_id);
$this->db->update(db_prefix() . 'appointly_appointments', [
'end_hour' => $data['appointment']['end_hour'],
]);
}
}
}
} else {
// For staff-only appointments, use the provided duration
if (! empty($data['appointment']['duration'])) {
if (empty($data['appointment']['start_hour']) && ! empty($data['appointment']['end_hour'])) {
// Calculate start_hour based on end_hour and duration
$duration = $data['appointment']['duration'];
$end_time = strtotime($data['appointment']['end_hour']);
$start_time = $end_time - ($duration * 60);
$data['appointment']['start_hour'] = date('H:i', $start_time);
// Update the database for future requests
$this->db->where('id', $appointment_id);
$this->db->update(db_prefix() . 'appointly_appointments', [
'start_hour' => $data['appointment']['start_hour'],
]);
} elseif (! empty($data['appointment']['start_hour']) && empty($data['appointment']['end_hour'])) {
// Calculate end_hour based on start_hour and duration
$duration = $data['appointment']['duration'];
$start_time = strtotime($data['appointment']['start_hour']);
$end_time = $start_time + ($duration * 60);
$data['appointment']['end_hour'] = date('H:i', $end_time);
// Update the database for future requests
$this->db->where('id', $appointment_id);
$this->db->update(db_prefix() . 'appointly_appointments', [
'end_hour' => $data['appointment']['end_hour'],
]);
}
}
}
// format the time with correct start and end hours
$data['appointment']['formatted_time'] = date('H:i A', strtotime($data['appointment']['start_hour'])) .
($data['appointment']['end_hour'] ? ' - ' . date('H:i A', strtotime($data['appointment']['end_hour'])) : '');
// Get client details if internal appointment
if ($data['appointment']['source'] == 'internal' && $data['appointment']['contact_id']) {
$this->load->model('clients_model');
$client = $this->clients_model->get_contact($data['appointment']['contact_id']);
if ($client) {
$client_info = $this->clients_model->get($client->userid);
$data['appointment']['details'] = [
'userid' => $client->userid,
'company' => $client_info ? $client_info->company : '',
'full_name' => $client->firstname . ' ' . $client->lastname,
'email' => $client->email,
'phone' => $client->phonenumber,
'address' => $client_info ? $client_info->address : '',
'city' => $client_info ? $client_info->city : '',
'state' => $client_info ? $client_info->state : '',
'zip' => $client_info ? $client_info->zip : '',
'country' => $client_info ? $client_info->country : '',
'full_address' => $this->format_full_address($client_info),
];
}
}
// Get lead details if lead-related appointment
if ($data['appointment']['source'] == 'lead_related' && $data['appointment']['contact_id']) {
$this->load->model('leads_model');
$lead = $this->leads_model->get($data['appointment']['contact_id']);
if ($lead) {
$data['appointment']['details'] = [
'userid' => null,
'company' => $lead->company,
'full_name' => $lead->name,
'email' => $lead->email,
'phone' => $lead->phonenumber,
'address' => $lead->address,
'city' => $lead->city,
'state' => $lead->state,
'zip' => $lead->zip,
'country' => $lead->country,
'full_address' => $this->format_full_address($lead),
];
}
}
// Get service details
if (isset($data['appointment']['service_id'])) {
$data['appointment']['service'] = $this->apm->get_service($data['appointment']['service_id']);
}
$data['appointment']['attendees'] = $attendees;
$data['google_meet_attendees'] = $google_meet_attendees;
// Get email tracking
$data['appointment']['email_tracking'] = get_tracked_emails($appointment_id, 'appointment');
// provider details
if ($data['appointment']['provider_id']) {
$provider = $this->staff_model->get($data['appointment']['provider_id']);
if ($provider) {
$data['appointment']['provider'] = [
'staffid' => $provider->staffid,
'full_name' => $provider->firstname . ' ' . $provider->lastname,
'email' => $provider->email,
'profile_image' => staff_profile_image_url($provider->staffid),
];
}
}
} else {
appointly_redirect_after_event('warning', _l('appointment_not_exists'));
}
if (! $data['appointment']) {
show_404();
}
// Check for pending reschedule requests for this appointment
$data['pending_reschedule'] = null;
if (staff_can('edit', 'appointments')) {
$this->db->select('rr.*, a.subject, a.name, a.email');
$this->db->from(db_prefix() . 'appointly_reschedule_requests rr');
$this->db->join(db_prefix() . 'appointly_appointments a', 'a.id = rr.appointment_id');
$this->db->where('rr.appointment_id', $appointment_id);
$this->db->where('rr.status', 'pending');
$pending_reschedule = $this->db->get()->row_array();
if ($pending_reschedule) {
$data['pending_reschedule'] = $pending_reschedule;
}
}
$data['google_api_key'] = get_option('google_api_key');
//dd($data);
$this->load->view('tables/appointment', $data);
}
/**
* Handle appointment update
*
* @return void
*/
public function update()
{
if (! staff_can('edit', 'appointments')) {
access_denied('Appointments');
}
header('Content-Type: application/json');
// Get all POST data
$updateData = $this->input->post();
// Special handling for lead-related appointments
if ($updateData['rel_type'] === 'lead_related') {
// For lead-related appointments, we need to set source and handle the lead ID
$updateData['source'] = 'lead_related';
if (! empty($updateData['rel_id'])) {
// Store lead ID in contact_id field
$updateData['contact_id'] = $updateData['rel_id'];
log_message('debug', 'Setting contact_id for lead-related appointment: ' . $updateData['contact_id']);
// Load lead data to enhance the appointment
$this->load->model('leads_model');
$lead = $this->leads_model->get($updateData['rel_id']);
if ($lead) {
// Set lead fields
$updateData['name'] = $lead->name;
$updateData['email'] = $lead->email;
$updateData['phone'] = $lead->phonenumber;
log_message('debug', 'Lead data loaded: ' . $lead->name);
}
}
} elseif ($updateData['rel_type'] === 'internal') {
// For internal appointments, ensure we have contact_id
$updateData['source'] = 'internal';
} elseif ($updateData['rel_type'] === 'external') {
// For external appointments, ensure name and email are provided
$updateData['source'] = 'external';
$updateData['contact_id'] = null;
} elseif ($updateData['rel_type'] === 'internal_staff') {
// For staff-only appointments, clear client-related fields
$updateData['source'] = 'internal_staff';
$updateData['contact_id'] = null;
$updateData['name'] = '';
$updateData['email'] = '';
$updateData['phone'] = '';
}
// Attempt to update the appointment
$success = $this->apm->update_appointment($updateData);
if ($success) {
echo json_encode([
'success' => true,
'result' => true,
]);
} else {
echo json_encode([
'success' => false,
'result' => false,
'message' => _l('appointment_update_failed'),
]);
}
}
/**
* Render table view
*
* @return void
*/
public function table()
{
if ($this->staff_no_view_permissions) {
access_denied('Appointments');
}
// Fix the status filter by explicitly setting the fully qualified table.column
$status_filters = [
'status_pending',
'status_in-progress',
'status_completed',
'status_cancelled',
'status_no-show'
];
foreach ($status_filters as $filter) {
if ($this->input->post($filter)) {
// Extract the status value (remove 'status_' prefix)
$status_value = str_replace('status_', '', $filter);
// Explicitly qualify the status column with the table name
$_POST[$filter] = db_prefix() . 'appointly_appointments.status = \'' . $status_value . '\'';
}
}
// Get regular appointments first
// here if google is not connected we have error when trying to filter by google calendar
$this->app->get_table_data(module_views_path('appointly', 'tables/index'));
// Check if Google sync is enabled and debug
if (get_option('appointments_googlesync_show_in_table') == 1 && appointlyGoogleAuth()) {
$this->load->model('googlecalendar');
$google_events = $this->googlecalendar->getEvents();
if ($google_events) {
$data = [];
foreach ($google_events as $event) {
$data[] = [
'id' => $event->id,
'subject' => $event->summary,
'description' => $event->description ?? '',
'date' => $event->start->dateTime,
'source' => 'google_calendar',
'google_event_id' => $event->id,
'google_calendar_link' => $event->htmlLink,
];
}
echo json_encode(['data' => $data]);
}
}
}
/**
* Stop recurring appointments
*
* @return void
*/
public function stop_recurring()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
if ($this->staff_no_edit_permissions) {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => _l('access_denied')
]);
return;
}
$appointment_id = $this->input->post('appointment_id');
if (!$appointment_id) {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => _l('appointment_not_found')
]);
return;
}
// Update appointment to stop recurring
$this->db->where('id', $appointment_id);
$this->db->update(db_prefix() . 'appointly_appointments', [
'recurring' => 0,
'recurring_type' => null,
'repeat_every' => null,
'custom_recurring' => 0,
'cycles' => 0,
'last_recurring_date' => null
]);
if ($this->db->affected_rows() > 0) {
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'message' => _l('recurring_stopped_successfully')
]);
} else {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => _l('error_occurred')
]);
}
}
/**
* Get contact data
*
* @return void
*/
public function fetch_contact_data()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$id = $this->input->post('contact_id');
$is_lead = $this->input->post('lead');
log_message('info', 'Appointment fetch_contact_data called - ID: ' . $id . ', is_lead: ' . $is_lead);
if (! $id) {
header('Content-Type: application/json');
echo json_encode([]);
return;
}
if ($is_lead) {
// Get lead data
$this->load->model('leads_model');
$lead = $this->leads_model->get($id);
if ($lead) {
// Format lead data to match expected structure
$formatted_data = [
'name' => $lead->name,
'email' => $lead->email,
'phonenumber' => $lead->phonenumber,
'address' => $lead->address,
];
header('Content-Type: application/json');
echo json_encode($formatted_data);
return;
}
} else {
// Get contact data
$this->load->model('clients_model');
$contact = $this->clients_model->get_contact($id);
if ($contact) {
// Get client/company data for additional details
$client = $this->clients_model->get($contact->userid);
// Format contact data
$formatted_data = [
'firstname' => $contact->firstname,
'lastname' => $contact->lastname,
'email' => $contact->email,
'phonenumber' => $contact->phonenumber,
'address' => $client ? $client->address : '',
];
header('Content-Type: application/json');
echo json_encode($formatted_data);
return;
}
}
// Return empty result if no data found
header('Content-Type: application/json');
echo json_encode([]);
}
/**
* Create appointment
*
* @return void
*/
public function create()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
if (!staff_can('create', 'appointments')) {
echo json_encode([
'success' => false,
'message' => _l('access_denied')
]);
return;
}
$data = $this->input->post();
header('Content-Type: application/json');
$appointment_id = $this->apm->create_appointment($data);
if ($appointment_id) {
$response = [
'success' => true,
'message' => _l('appointment_created'),
'appointment_id' => $appointment_id
];
// Check if Outlook integration was requested
$outlook_integration_data = $this->session->userdata('appointly_outlook_integration');
if ($outlook_integration_data && $outlook_integration_data['appointment_id'] == $appointment_id) {
$response['outlook_integration'] = $outlook_integration_data;
// Clear the session data after use
$this->session->unset_userdata('appointly_outlook_integration');
}
echo json_encode($response);
} else {
echo json_encode([
'success' => false,
'message' => _l('appointment_could_not_be_created')
]);
}
}
/**
* Delete Google synced appointment
*
* @param string $google_event_id Google event ID
*
* @return void
*/
public function deleteGoogleSyncedAppointment($google_event_id)
{
if (! $this->input->is_ajax_request()) {
show_404();
}
// Load Google Calendar model
$this->load->model('appointly/googlecalendar');
// Check if user is authenticated with Google
if (! appointlyGoogleAuth()) {
echo json_encode([
'success' => false,
'message' => _l('appointment_google_not_authenticated'),
]);
return;
}
// Try to delete the event from Google Calendar
$success = $this->googlecalendar->deleteEvent($google_event_id);
// Get the appointment ID from the Google event ID
$appointment = $this->db->get_where(db_prefix() . 'appointly_appointments', ['google_event_id' => $google_event_id])->row();
if ($appointment) {
// Direct SQL update to clear Google-related fields
$this->db->query("
UPDATE " . db_prefix() . "appointly_appointments
SET google_event_id = NULL,
google_calendar_link = NULL,
google_meet_link = NULL,
google_added_by_id = NULL
WHERE google_event_id = ?
", [$google_event_id]);
}
if ($success) {
// Also remove from the local cache/array of Google events
// This is important to ensure it doesn't show in the table anymore
$this->session->set_userdata(
'appointly_google_events_deleted',
array_merge($this->session->userdata('appointly_google_events_deleted') ?: [], [$google_event_id])
);
echo json_encode([
'success' => true,
'message' => _l('appointment_deleted'),
]);
} else {
echo json_encode([
'success' => false,
'message' => _l('appointment_delete_failed'),
]);
}
}
/**
* Approve new appointment
*
* @return void
*/
public function approve()
{
if (! staff_can('approve', 'appointments')) {
access_denied('Appointments');
}
if ($this->input->is_ajax_request()) {
echo json_encode([
'result' => $this->apm->approve_appointment($this->input->post('appointment_id')),
]);
return;
}
if ($this->apm->approve_appointment($this->input->get('appointment_id'))) {
appointly_redirect_after_event('success', _l('appointment_appointment_approved'));
}
}
/**
* Mark appointment as finished
*
* @return bool
*/
public function finished()
{
if (!staff_can('edit', 'appointments')) {
access_denied('Appointments');
}
return $this->apm->mark_as_finished($this->input->post('id'));
}
/**
* Mark appointment as ongoing
*
* @return void
*/
public function mark_as_ongoing_appointment()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
if (!staff_can('edit', 'appointments')) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => _l('access_denied')]);
return;
}
$id = $this->input->post('id');
$success = $this->apm->mark_as_ongoing($id);
header('Content-Type: application/json');
echo json_encode([
'success' => (bool) $success,
'message' => $success ? _l('appointment_marked_as_ongoing') : _l('appointment_status_change_failed'),
]);
}
/**
* Mark appointment as cancelled
*
* @return void|boolean
*/
public function cancel_appointment()
{
if (!staff_can('edit', 'appointments')) {
access_denied('Appointments');
}
return $this->apm->cancel_appointment($this->input->post('id'));
}
/**
* Mark appointment as no-show
*
* @return void
*/
public function mark_as_no_show()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
if (!staff_can('edit', 'appointments')) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => _l('access_denied')]);
return;
}
$id = $this->input->post('id');
$success = $this->apm->mark_as_no_show($id);
header('Content-Type: application/json');
echo json_encode([
'success' => (bool) $success,
'message' => $success ? _l('appointment_marked_as_no_show') : _l('appointment_status_change_failed'),
]);
}
/**
* Send appointment early reminders
*
* @return void
*/
public function send_appointment_early_reminders()
{
$result = ['success' => false];
if ($this->apm->send_appointment_early_reminders($this->input->post('id'))) {
$result['success'] = true;
}
echo json_encode($result);
}
/**
* Add event to google calendar
*
* @return void
*/
public function addEventToGoogleCalendar()
{
if (! staff_can('edit', 'appointments') || ! $this->input->is_ajax_request()) {
access_denied('Appointments');
}
$data = $this->input->post();
if (! empty($data)) {
header('Content-Type: application/json');
$result = $this->apm->add_event_to_google_calendar($data);
if ($result) {
echo json_encode($result);
}
}
}
/**
* Request new appointment feedback
*
* @return void
*/
public function requestAppointmentFeedback()
{
$id = $this->input->post('appointment_id');
if (! empty($id)) {
header('Content-Type: application/json');
$result = $this->apm->request_appointment_feedback($id);
echo $result;
}
}
/**
* Delete Outlook event from appointment
*
* @return void
*/
public function deleteOutlookEvent()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$appointment_id = $this->input->post('appointment_id');
$outlook_event_id = $this->input->post('outlook_event_id');
if (empty($appointment_id) || empty($outlook_event_id)) {
echo json_encode([
'success' => false,
'message' => _l('appointment_missing_required_fields'),
]);
return;
}
// Get the appointment to verify it exists
$appointment = $this->db->get_where(db_prefix() . 'appointly_appointments', ['id' => $appointment_id])->row();
if (!$appointment) {
echo json_encode([
'success' => false,
'message' => _l('appointment_not_found'),
]);
return;
}
// Clear Outlook-related fields from the appointment
$this->db->where('id', $appointment_id);
$success = $this->db->update(db_prefix() . 'appointly_appointments', [
'outlook_event_id' => null,
'outlook_calendar_link' => null,
]);
if ($success) {
echo json_encode([
'success' => true,
'message' => _l('appointment_outlook_integration_removed'),
]);
} else {
echo json_encode([
'success' => false,
'message' => _l('appointment_outlook_removal_failed'),
]);
}
}
/**
* Delete Google event from appointment
*
* @return void
*/
public function deleteGoogleEvent()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$appointment_id = $this->input->post('appointment_id');
$google_event_id = $this->input->post('google_event_id');
if (empty($appointment_id) || empty($google_event_id)) {
echo json_encode([
'success' => false,
'message' => _l('appointment_missing_required_fields'),
]);
return;
}
// Get the appointment to verify it exists
$appointment = $this->db->get_where(db_prefix() . 'appointly_appointments', ['id' => $appointment_id])->row();
if (!$appointment) {
echo json_encode([
'success' => false,
'message' => _l('appointment_not_found'),
]);
return;
}
// Load Google Calendar model for deletion
$this->load->model('appointly/googlecalendar');
// Try to delete from Google Calendar if authenticated
$google_deleted = false;
if (appointlyGoogleAuth()) {
$google_deleted = $this->googlecalendar->deleteEvent($google_event_id);
}
// Clear Google-related fields from the appointment regardless of Google API result
$this->db->where('id', $appointment_id);
$success = $this->db->update(db_prefix() . 'appointly_appointments', [
'google_event_id' => null,
'google_calendar_link' => null,
'google_meet_link' => null,
'google_added_by_id' => null,
]);
if ($success) {
$message = $google_deleted
? _l('appointment_google_integration_removed_and_deleted')
: _l('appointment_google_integration_removed');
echo json_encode([
'success' => true,
'message' => $message,
]);
} else {
echo json_encode([
'success' => false,
'message' => _l('appointment_google_removal_failed'),
]);
}
}
/**
* Get attendee details
*
* @return void
*/
public function getAttendeeData()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
if ($this->input->post('ids')) {
header('Content-Type: application/json');
echo json_encode($this->atm->details($this->input->post('ids')));
}
}
/**
* Get contact email details
*
* @return void
*/
public function getContactEmail()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$contact_id = $this->input->post('contact_id');
if ($contact_id) {
// Get contact details from database
$this->db->select('email, firstname, lastname');
$this->db->from(db_prefix() . 'contacts');
$this->db->where('id', $contact_id);
$contact = $this->db->get()->row_array();
if ($contact) {
$response = [
'email' => $contact['email'],
'name' => trim($contact['firstname'] . ' ' . $contact['lastname'])
];
} else {
$response = [
'error' => 'Contact not found'
];
}
header('Content-Type: application/json');
echo json_encode($response);
} else {
header('Content-Type: application/json');
echo json_encode(['error' => 'No contact ID provided']);
}
}
/**
* Add new outlook event to calendar
*
* @return void
*/
public function newOutlookEvent()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$data = $this->input->post();
if (! empty($data)) {
header('Content-Type: application/json');
echo json_encode(['result' => $this->apm->insert_new_outlook_event($data)]);
}
}
/**
* Add new outlook event to calendar from existing appointment
*
* @return void
*/
public function update_outlook_event()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$data = $this->input->post();
if (! empty($data)) {
header('Content-Type: application/json');
echo json_encode(['result' => $this->apm->update_outlook_event($data)]);
}
}
/**
* Send custom email to request meet via Google Meet
*
* @return void
*/
public function send_appointment_custom_email()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$data = $this->input->post();
if (! empty($data)) {
$result = $this->apm->send_google_meet_request_email($data);
// Handle both old boolean response and new array response for backward compatibility
if (is_array($result)) {
header('Content-Type: application/json');
echo json_encode($result);
} else {
// Legacy boolean response
header('Content-Type: application/json');
echo json_encode([
'success' => $result,
'message' => $result ? _l('appointment_email_sent_success') : _l('appointment_email_sent_failed')
]);
}
} else {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'No data provided in sending custom email'
]);
}
}
/**
* Delete appointment
*
* @param string|int $id appointment ID or Google event ID
*
* @return void
*/
public function delete($id)
{
if (! staff_can('delete', 'appointments')) {
access_denied('Appointments');
}
if ($this->input->is_ajax_request() && $id) {
// Use the model to handle all deletion logic
$result = $this->apm->delete_appointment($id);
// Return the result from the model
echo json_encode($result);
die;
}
}
/**
* AJAX endpoint for saving blocked days
*
* @return void
*/
public function save_blocked_days_ajax()
{
// Check for AJAX request
if (! $this->input->is_ajax_request()) {
show_404();
}
// Check permissions
if (! is_admin()) {
echo json_encode([
'success' => false,
'message' => _l('access_denied'),
]);
return;
}
// Log the entire POST data for debugging
$post_data = $this->input->post();
// Get blocked days from POST
$blocked_days = $this->input->post('blocked_days');
if ($blocked_days) {
// Validate JSON format
$decoded = json_decode($blocked_days, true);
if (json_last_error() === JSON_ERROR_NONE) {
// Update option in database
update_option('appointly_blocked_days', $blocked_days);
echo json_encode([
'success' => true,
'message' => _l('settings_updated'),
'data' => $decoded,
]);
} else {
echo json_encode([
'success' => false,
'message' => 'Invalid JSON format: ' . json_last_error_msg(),
'received_data' => $blocked_days,
]);
}
} else {
echo json_encode([
'success' => false,
'message' => 'No data provided',
]);
}
}
/**
* Get available time slots for a specific date
*/
public function get_available_time_slots()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$date = $this->input->post('date');
$provider_id = $this->input->post('provider_id');
$service_id = $this->input->post('service_id');
$appointment_id = $this->input->post('appointment_id'); // For excluding current appointment when editing
$timezone = $this->input->post('timezone') ?? get_option('default_timezone');
$rel_type = $this->input->post('rel_type'); // Get appointment type
// Ensure date is in the correct format (YYYY-MM-DD)
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$timestamp = strtotime($date);
if ($timestamp) {
$date = date('Y-m-d', $timestamp);
//log_message('debug', "Appointly: Converted date format from '{$this->input->post('date')}' to '{$date}'");
} else {
// If strtotime fails, try to detect the format using common patterns
$formats = [
'm.d.Y' => '/^\d{1,2}\.\d{1,2}\.\d{4}$/',
'd.m.Y' => '/^\d{1,2}\.\d{1,2}\.\d{4}$/',
'm/d/Y' => '/^\d{1,2}\/\d{1,2}\/\d{4}$/',
'd/m/Y' => '/^\d{1,2}\/\d{1,2}\/\d{4}$/',
'd-m-Y' => '/^\d{1,2}-\d{1,2}-\d{4}$/',
'm-d-Y' => '/^\d{1,2}-\d{1,2}-\d{4}$/',
];
foreach ($formats as $format => $pattern) {
if (preg_match($pattern, $date)) {
$date_obj = DateTime::createFromFormat($format, $date);
if ($date_obj) {
$date = $date_obj->format('Y-m-d');
log_message('debug', "Appointly: Converted date from format {$format}: '{$this->input->post('date')}' to '{$date}'");
break;
}
}
}
// If still not in correct format, set to today
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
}
}
// Get service details for duration
$service = $this->apm->get_service($service_id);
$duration = $service ? $service->duration : 60;
// For staff-only appointments, use special handling
$is_internal_staff = ($rel_type === 'internal_staff');
$staff_respect_availability = get_option('appointly_staff_respect_availability') == 1;
// Get availability restrictions - not needed for staff appointments (they use simple 30-min slots)
$available_slots = [];
if (!$is_internal_staff) {
$available_slots = $this->apm->get_available_time_slots($provider_id, $date, $service_id, $timezone, $appointment_id);
}
// Get booked/busy time slots - needed for staff appointments only when availability setting is enabled
$busy_slots = [];
if (!$is_internal_staff || ($is_internal_staff && $staff_respect_availability)) {
$busy_slots = $this->apm->get_busy_times_by_staff($provider_id, $date, $appointment_id);
}
// Time format based on app settings
$time_format = get_option('time_format') == 24 ? 'H:i' : 'g:i A';
// Get the current appointment time if we're editing
$current_appointment = null;
if ($appointment_id) {
$current_appointment = $this->db->where('id', $appointment_id)
->get(db_prefix() . 'appointly_appointments')
->row_array();
}
$time_slots = [];
// Handle internal staff appointments differently - they have more flexible time slots
if ($is_internal_staff) {
// For internal staff meetings, ALWAYS allow full day scheduling
// Internal meetings should be flexible regardless of availability settings
// We'll show visual warnings for conflicts, but allow booking at any time
$start_time = strtotime('00:00');
$end_time = strtotime('23:30'); // Last slot starts at 23:30, ends at 00:00
while ($start_time <= $end_time) {
$slot_start = date('H:i', $start_time);
$slot_end = date('H:i', strtotime('+30 minutes', $start_time));
$is_current = false;
if (
$current_appointment && ! empty($current_appointment['start_hour'])
&& $slot_start == date('H:i', strtotime($current_appointment['start_hour']))
) {
$is_current = true;
}
// Format display text
$start_formatted = date($time_format, strtotime($slot_start));
$end_formatted = date($time_format, strtotime($slot_end));
$display_text = $start_formatted . ' - ' . $end_formatted;
if ($is_current) {
$display_text .= ' (current)';
}
// For staff appointments, ALWAYS check for conflicts but don't block selection
// Show visual warning instead of making slot unavailable
$is_booked = false;
$unavailable_reason = '';
$has_conflict = false;
// Always check for busy times to provide visual feedback
foreach ($busy_slots as $busy) {
// Skip current appointment
if ($appointment_id && $busy['id'] == $appointment_id) {
continue;
}
$busy_start = strtotime($busy['start_hour']);
$busy_end = strtotime($busy['end_hour']);
// Apply buffer times from the existing appointment if they exist
if (isset($busy['buffer_before']) && $busy['buffer_before']) {
$busy_start -= ($busy['buffer_before'] * 60);
}
if (isset($busy['buffer_after']) && $busy['buffer_after']) {
$busy_end += ($busy['buffer_after'] * 60);
}
$slot_start_time = strtotime($slot_start);
$slot_end_time = strtotime($slot_end);
// Check if there's an overlap
if (
($slot_start_time >= $busy_start && $slot_start_time < $busy_end) ||
($slot_end_time > $busy_start && $slot_end_time <= $busy_end) ||
($slot_start_time <= $busy_start && $slot_end_time >= $busy_end)
) {
$has_conflict = true;
// Get service name or appointment subject
$conflict_info = '';
if (! empty($busy['service_id'])) {
$busy_service = $this->apm->get_service($busy['service_id']);
if ($busy_service) {
$conflict_info = $busy_service->name;
}
} elseif (! empty($busy['subject'])) {
$conflict_info = $busy['subject'];
}
$unavailable_reason = _l('appointment_slot_already_booked');
if (! empty($conflict_info)) {
$unavailable_reason .= ' - ' . $conflict_info;
}
break;
}
}
// Add slot to list
// For staff appointments, mark as available but flag conflicts as warnings
$time_slots[] = [
'value' => $slot_start,
'text' => $display_text,
'end_time' => $slot_end,
'available' => true, // Always available for staff appointments
'has_conflict' => $has_conflict, // But show warning if there's a conflict
'is_current' => $is_current,
'unavailable_reason' => $unavailable_reason,
];
// Move to next time slot (30 minutes)
$start_time = strtotime('+30 minutes', $start_time);
}
} else {
// Regular appointment handling for service-based appointments
// First add all available slots
foreach ($available_slots as $slot) {
$is_current = false;
// If editing, check if this is the current appointment's time
if (
$current_appointment && ! empty($current_appointment['start_hour'])
&& $slot['start'] == $current_appointment['start_hour']
) {
$is_current = true;
}
// Format the start and end times for display
$start_formatted = date($time_format, strtotime($slot['start']));
$end_formatted = date($time_format, strtotime($slot['end']));
$display_text = $start_formatted . ' - ' . $end_formatted;
// Add "current" label if this is the current appointment's time
if ($is_current) {
$display_text .= ' (current)';
}
$time_slots[] = [
'value' => $slot['start'],
'text' => $display_text,
'end_time' => $slot['end'],
'available' => true,
'is_current' => $is_current,
];
}
// Now add all booked/busy slots - these will be marked as unavailable in the dropdown
foreach ($busy_slots as $busy) {
// Skip if this is the current appointment being edited
if ($appointment_id && $busy['id'] == $appointment_id) {
continue;
}
$start_hour = $busy['start_hour'];
$end_hour = $busy['end_hour'];
// Format for display
$start_formatted = date($time_format, strtotime($start_hour));
$end_formatted = date($time_format, strtotime($end_hour));
$display_text = $start_formatted . ' - ' . $end_formatted;
// Get service name if available
$service_name = '';
if (! empty($busy['service_id'])) {
$busy_service = $this->apm->get_service($busy['service_id']);
if ($busy_service) {
$service_name = $busy_service->name;
}
}
// Add a reason for unavailability
$unavailable_reason = _l('appointment_slot_already_booked');
if (! empty($service_name)) {
$unavailable_reason .= ' - ' . $service_name;
}
// Add to time slots as unavailable
$time_slots[] = [
'value' => $start_hour,
'text' => $display_text,
'end_time' => $end_hour,
'available' => false,
'unavailable_reason' => $unavailable_reason,
];
}
}
// Sort time slots by start time
usort($time_slots, function ($a, $b) {
return strtotime($a['value']) - strtotime($b['value']);
});
echo json_encode([
'success' => true,
'time_slots' => $time_slots,
'service_duration' => $duration,
]);
}
public function save_notes_from_appointment_view()
{
if (! $this->input->post()) {
redirect(admin_url('appointly/appointments'));
}
$appointment_id = $this->input->post('appointment_id');
$notes = $this->input->post('notes');
echo $this->apm->update_appointment_private_notes($appointment_id, $notes);
}
public function save_appointment_description()
{
if (! staff_can('edit', 'appointments')) {
ajax_access_denied();
}
if ($this->input->post()) {
$data = [
'description' => $this->input->post('description'),
'appointment_id' => $this->input->post('appointment_id'),
];
$success = $this->apm->save_appointment_description($data['appointment_id'], $data['description']);
if ($success) {
echo json_encode([
'success' => true,
'message' => _l('appointment_description_updated'),
]);
} else {
echo json_encode([
'success' => false,
'message' => _l('appointment_description_update_failed'),
]);
}
}
}
public function save_outlook_event_id()
{
$appointment_id = $this->input->post('appointment_id');
$outlook_event_id = $this->input->post('outlook_event_id');
$outlook_calendar_link = $this->input->post('outlook_calendar_link');
$success = $this->apm->save_outlook_event_id(
(int) $appointment_id,
$outlook_event_id,
$outlook_calendar_link,
get_staff_user_id()
);
echo json_encode([
'success' => $success,
'message' => $success ? _l('appointment_outlook_event_saved') : _l('appointment_outlook_event_save_failed'),
]);
}
/**
* Update integration data for appointment (Outlook)
*
* @return void
*/
public function update_integration_data()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
$appointment_id = $this->input->post('appointment_id');
$integration_type = $this->input->post('integration_type'); // 'outlook'
$success = false;
$message = '';
if ($integration_type === 'outlook') {
$outlook_event_id = $this->input->post('outlook_event_id');
$outlook_calendar_link = $this->input->post('outlook_calendar_link');
$success = $this->apm->save_outlook_event_id(
(int) $appointment_id,
$outlook_event_id,
$outlook_calendar_link,
get_staff_user_id()
);
$message = $success ? _l('appointment_outlook_event_saved') : _l('appointment_outlook_event_save_failed');
}
echo json_encode([
'success' => $success,
'message' => $message,
]);
}
/**
* Get appointment data for invoice
*
* This endpoint provides appointment data for the invoice creation form
*
* @return void
*/
public function get_appointment_data_for_invoice()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$appointment_id = $this->input->post('appointment_id');
$appointment = $this->apm->get_appointment_data($appointment_id);
// Get client ID if it's an internal appointment with a contact
if ($appointment['source'] == 'internal' && ! empty($appointment['contact_id'])) {
$this->load->model('clients_model');
$contact = $this->clients_model->get_contact($appointment['contact_id']);
if ($contact) {
// Contact's userid field refers to the client/customer ID (company)
$appointment['client_id'] = $contact->userid;
}
}
// Get service details if available
if (! empty($appointment['service_id'])) {
$appointment['service'] = $this->apm->get_service($appointment['service_id']);
}
echo json_encode([
'success' => true,
'data' => $appointment,
]);
}
/**
* Get appointment data for tooltips
* Returns rich appointment information for calendar tooltips
*/
public function get_appointment_data()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$appointment_id = $this->input->post('appointment_id');
// Get appointment with service and provider details
$this->db->select('a.*, s.name as service_name, s.color as service_color, s.duration as service_duration, st.firstname as provider_firstname, st.lastname as provider_lastname');
$this->db->from(db_prefix() . 'appointly_appointments a');
$this->db->join(db_prefix() . 'appointly_services s', 's.id = a.service_id', 'left');
$this->db->join(db_prefix() . 'staff st', 'st.staffid = a.provider_id', 'left');
$this->db->where('a.id', $appointment_id);
$appointment = $this->db->get()->row_array();
// Check permissions
if (! is_admin() && $appointment['created_by'] != get_staff_user_id()) {
// Check if user is an attendee
$this->db->where('appointment_id', $appointment_id);
$this->db->where('staff_id', get_staff_user_id());
$attendee = $this->db->get(db_prefix() . 'appointly_attendees')->row();
if (! $attendee) {
access_denied();
}
}
echo json_encode([
'success' => true,
'data' => $appointment,
]);
}
/**
* Convert appointment to invoice
*
* Converts an appointment to an invoice with pre-filled data
* Only works for internal appointments with contacts
*
* @return void
*/
public function convert_to_invoice()
{
// Check permissions
if (! staff_can('create', 'invoices')) {
access_denied();
}
// Get appointment ID
$appointment_id = $this->input->post('appointment_id');
if (! $appointment_id) {
echo json_encode(['success' => false, 'message' => _l('appointment_convert_to_invoice_no_appointment_id')]);
return;
}
$appointment = $this->apm->get_appointment_data($appointment_id);
if (! $appointment) {
echo json_encode(['success' => false, 'message' => _l('appointment_convert_to_invoice_appointment_not_found')]);
return;
}
// Check if appointment is internal and has contact_id
if ($appointment['source'] != 'internal' || empty($appointment['contact_id'])) {
echo json_encode(['success' => false, 'message' => _l('appointment_convert_to_invoice_only_contacts_allowed')]);
return;
}
// Get client ID from contact
// client/customer is the company entity, while contacts are individual people associated with that company
$this->load->model('clients_model');
$contact = $this->clients_model->get_contact($appointment['contact_id']);
if (! $contact || ! $contact->userid) {
echo json_encode(['success' => false, 'message' => _l('appointment_convert_to_invoice_unable_to_find_client_for_this_contact')]);
return;
}
// The userid field in a contact refers to the client_id (customer/company ID)
$client_id = $contact->userid;
// Get client info for billing details
$client = $this->clients_model->get($client_id);
if (! $client) {
echo json_encode(['success' => false, 'message' => _l('appointment_convert_to_invoice_client_not_found')]);
return;
}
// Get service details for invoice item
$service_details = [];
if (! empty($appointment['service_id'])) {
$service = $this->apm->get_service($appointment['service_id']);
if ($service) {
$service_details = [
'name' => $service->name,
'price' => $service->price,
'description' => $service->description,
];
}
}
// Prepare invoice data
$invoice_data = [
'clientid' => $client_id,
'date' => date('Y-m-d'),
'duedate' => date('Y-m-d', strtotime('+' . get_option('invoice_due_after') . ' DAY')),
'currency' => get_base_currency()->id,
'number' => get_option('next_invoice_number'),
'status' => 6,
'tags' => strtolower(_l('appointment_appointments')),
// Set the sale agent as the appointment organizer
'sale_agent' => $appointment['created_by'] ?? get_staff_user_id(),
'billing_street' => $client->billing_street ?? '',
'billing_city' => $client->billing_city ?? '',
'billing_state' => $client->billing_state ?? '',
'billing_zip' => $client->billing_zip ?? '',
'billing_country' => $client->billing_country ?? 0,
'shipping_street' => $client->shipping_street ?? '',
'shipping_city' => $client->shipping_city ?? '',
'shipping_state' => $client->shipping_state ?? '',
'shipping_zip' => $client->shipping_zip ?? '',
'shipping_country' => $client->shipping_country ?? 0,
'terms' => get_option('predefined_terms_invoice'),
'clientnote' => get_option('predefined_clientnote_invoice'),
// Add appointment private notes to admin notes
'adminnote' => ! empty($appointment['notes']) ? strip_tags($appointment['notes']) : '',
'addedfrom' => get_staff_user_id(),
'show_quantity_as' => 1,
'newitems' => [
[
'description' => ! empty($appointment['subject']) ? strip_tags($appointment['subject']) : 'Appointment Service',
'long_description' => 'Appointment on ' . $appointment['date'] .
(! empty($appointment['start_hour']) ? ' at ' . date('H:i', strtotime($appointment['start_hour'])) : '') .
(! empty($appointment['description']) ? "\n\n" . strip_tags($appointment['description']) : ''),
'qty' => 1,
'unit' => '',
'rate' => ! empty($service_details['price']) ? $service_details['price'] : 0,
'taxname' => $this->getInvoiceTaxName(),
'order' => 1,
],
],
];
// Load invoices model
$this->load->model('invoices_model');
// Create the invoice
$invoice_id = $this->invoices_model->add($invoice_data);
if ($invoice_id) {
// Update appointment with invoice ID
$this->db->where('id', $appointment_id);
$this->db->update(db_prefix() . 'appointly_appointments', [
'invoice_id' => $invoice_id,
'invoice_date' => date('Y-m-d H:i:s'),
]);
// Return success with invoice ID
echo json_encode([
'success' => true,
'invoice_id' => $invoice_id,
'message' => _l('appointment_converted_to_invoice'),
]);
} else {
echo json_encode(['success' => false, 'message' => _l('appointment_convert_to_invoice_failed')]);
}
}
/**
* Get tax name array for invoice conversion
* Creates a tax entry with the configured VAT percentage
*
* @return array Tax name array for invoice items
*/
private function getInvoiceTaxName()
{
$vat_percentage = get_option('appointly_invoice_default_vat', 0);
if ($vat_percentage <= 0) {
return [];
}
// Create tax name in the format expected by the invoice system
$tax_name = 'VAT|' . $vat_percentage;
return [$tax_name];
}
/**
* Get providers associated with a specific service
* Used for dynamic provider loading when selecting a service
*/
public function get_providers_by_service()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$service_id = $this->input->post('service_id');
if (! $service_id) {
header('Content-Type: application/json');
echo json_encode([]);
return;
}
// Get service providers from the linking table
$this->db->select('s.staffid, s.firstname, s.lastname, ss.is_primary');
$this->db->from(db_prefix() . 'staff s');
$this->db->join(
db_prefix() . 'appointly_service_staff ss',
's.staffid = ss.staff_id',
'inner'
);
$this->db->where('ss.service_id', $service_id);
$this->db->where('s.active', 1);
$providers = $this->db->get()->result_array();
// Format the providers data
$formatted_providers = [];
foreach ($providers as $provider) {
$formatted_providers[] = [
'id' => $provider['staffid'],
'staffid' => $provider['staffid'],
'firstname' => $provider['firstname'],
'lastname' => $provider['lastname'],
'name' => $provider['firstname'] . ' ' . $provider['lastname'],
'is_primary' => $provider['is_primary'],
];
}
header('Content-Type: application/json');
echo json_encode($formatted_providers);
}
/**
* Get available dates for a provider
* Considers company schedule, staff hours, and existing appointments
*/
public function get_available_dates()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$service_id = $this->input->post('service_id');
$provider_id = $this->input->post('provider_id');
$timezone = $this->input->post('timezone') ?? get_option('default_timezone');
// Get working hours for this provider
$working_hours = $this->apm->get_staff_working_hours($provider_id);
// Log what we found for debugging
log_message('debug', "Staff ID {$provider_id} working hours: " . json_encode($working_hours));
// Get ALL company schedule days (not just enabled ones)
$this->db->select('*');
$this->db->from(db_prefix() . 'appointly_company_schedule');
$company_schedule = $this->db->get()->result_array();
log_message('debug', "Company schedule (all days): " . count($company_schedule));
// Organize by day for easier lookup
$company_schedule_by_day = [];
foreach ($company_schedule as $day) {
$company_schedule_by_day[$day['weekday']] = $day;
}
// Create array of available days
$available_days = [];
$start_date = new DateTime('today', new DateTimeZone($timezone));
// Check next 30 days
for ($i = 0; $i < 30; $i++) {
$current_date = clone $start_date;
$current_date->add(new DateInterval("P{$i}D"));
$day_name = $current_date->format('l'); // Get day name (Monday, Tuesday, etc.)
// Check if this day is available
$day_available = false;
// Check if staff has specific hours for this day
if (isset($working_hours[$day_name])) {
// Staff has hours set for this day
if ($working_hours[$day_name]['is_available']) {
// Staff is available on this day
$day_available = true;
log_message('debug', "Day {$day_name} available: Staff has hours and is available");
}
} else {
// Staff has no hours set for this day, check company schedule
if (isset($company_schedule_by_day[$day_name])) {
// Check if company has this day enabled in schedule
$day_available = (bool)$company_schedule_by_day[$day_name]['is_enabled'];
log_message('debug', "Day {$day_name} available: " . ($day_available ? 'true' : 'false') . " (using company schedule, no staff hours)");
}
}
if ($day_available) {
$available_days[] = $current_date->format('Y-m-d');
}
}
echo json_encode([
'success' => true,
'available_dates' => $available_days,
]);
}
/**
* Change appointment status
*
* @return void
*/
public function change_appointment_status()
{
if (! $this->input->is_ajax_request()) {
show_404();
}
$id = $this->input->post('id');
$status = $this->input->post('status');
// Validate status value
$valid_statuses = ['pending', 'cancelled', 'completed', 'no-show', 'in-progress'];
if (! in_array($status, $valid_statuses)) {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Invalid status value',
]);
return;
}
// Handle specific status changes that need extra processing
if ($status === 'in-progress') {
// Send notifications as done in approve_appointment
$this->apm->appointment_approve_notification_and_sms_triggers($id);
// Update external_notification_date for tracking purposes
$this->db->where('id', $id);
$this->db->update(db_prefix() . 'appointly_appointments', [
'external_notification_date' => date('Y-m-d'),
]);
// Auto-add to Google Calendar if setting is enabled
if (get_option('appointly_auto_add_to_google_on_approval') == '1' && appointlyGoogleAuth()) {
$this->apm->auto_add_appointment_to_google_calendar($id);
}
}
// Change the status
$success = $this->apm->change_appointment_status($id, $status);
header('Content-Type: application/json');
echo json_encode([
'success' => (bool) $success,
'message' => $success
? _l('appointment_status_changed_successfully')
: _l('appointment_status_change_failed'),
]);
}
/**
* Approve reschedule request
*
* @return void
*/
public function approve_reschedule()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
$reschedule_id = $this->input->post('reschedule_id');
if (!$reschedule_id) {
echo json_encode(['success' => false, 'message' => 'Missing reschedule ID']);
return;
}
// Get reschedule request data
$reschedule = $this->apm->get_reschedule_request($reschedule_id);
if (!$reschedule || $reschedule['status'] != 'pending') {
echo json_encode(['success' => false, 'message' => 'Invalid reschedule request']);
return;
}
$success = $this->apm->approve_reschedule_request($reschedule_id, get_staff_user_id());
if ($success) {
// Get full appointment data for email template
$appointment = $this->apm->get_appointment_data($reschedule['appointment_id']);
if ($appointment) {
// Send approval notification to client using correct appointly pattern
$template = mail_template(
'appointly_appointment_reschedule_approved_to_contact',
'appointly',
array_to_object($appointment)
);
@$template->send();
}
// Log the approval using standard Perfex logging
log_activity('Appointment Reschedule Approved [AppointmentID: ' . $reschedule['appointment_id'] . ', New Date: ' . $reschedule['requested_date'] . ' ' . $reschedule['requested_time'] . ', Approved by: ' . get_staff_user_id() . ']');
echo json_encode([
'success' => true,
'message' => _l('appointment_reschedule_approved_successfully')
]);
} else {
echo json_encode([
'success' => false,
'message' => _l('appointment_reschedule_approval_failed')
]);
}
}
/**
* Deny reschedule request
*
* @return void
*/
public function deny_reschedule()
{
if (!$this->input->is_ajax_request()) {
show_404();
}
$reschedule_id = $this->input->post('reschedule_id');
$denial_reason = $this->input->post('denial_reason');
if (!$reschedule_id) {
echo json_encode(['success' => false, 'message' => 'Missing reschedule ID']);
return;
}
if (empty($denial_reason)) {
echo json_encode(['success' => false, 'message' => 'Denial reason is required']);
return;
}
// Get reschedule request data
$reschedule = $this->apm->get_reschedule_request($reschedule_id);
if (!$reschedule || $reschedule['status'] != 'pending') {
echo json_encode(['success' => false, 'message' => 'Invalid reschedule request']);
return;
}
$success = $this->apm->deny_reschedule_request($reschedule_id, get_staff_user_id(), $denial_reason);
if ($success) {
// Get full appointment data for email template
$appointment = $this->apm->get_appointment_data($reschedule['appointment_id']);
if ($appointment) {
// Add denial reason to appointment data for merge fields
$appointment['reschedule_denial_reason'] = $denial_reason;
// Send denial notification to client using correct appointly pattern
$template = mail_template(
'appointly_appointment_reschedule_denied_to_contact',
'appointly',
array_to_object($appointment)
);
@$template->send();
}
// Log the denial using standard Perfex logging
log_activity('Appointment Reschedule Denied [AppointmentID: ' . $reschedule['appointment_id'] . ', Denial Reason: ' . $denial_reason . ', Denied by: ' . get_staff_user_id() . ']');
echo json_encode([
'success' => true,
'message' => _l('appointment_reschedule_denied_successfully')
]);
} else {
echo json_encode([
'success' => false,
'message' => _l('appointment_reschedule_denial_failed')
]);
}
}
/**
* Format full address from client or lead data
*
* @param object|array $data Client or lead data object/array
* @return string Formatted full address
*/
private function format_full_address($data)
{
if (!$data) {
return '';
}
// Convert object to array for consistent handling
if (is_object($data)) {
$data = (array) $data;
}
$address_parts = [];
// Add address components in logical order
if (!empty($data['address'])) {
$address_parts[] = $data['address'];
}
if (!empty($data['city'])) {
$address_parts[] = $data['city'];
}
if (!empty($data['state'])) {
$address_parts[] = $data['state'];
}
if (!empty($data['zip'])) {
$address_parts[] = $data['zip'];
}
if (!empty($data['country'])) {
// If country is numeric (country ID), get country name using Perfex helper
if (is_numeric($data['country']) && $data['country'] > 0) {
$country_name = get_country_name($data['country']);
if ($country_name) {
$address_parts[] = $country_name;
}
} else {
// If country is already a string, use it directly
$address_parts[] = $data['country'];
}
}
// Join with commas and return
return implode(', ', array_filter($address_parts));
}
/**
* Generate .ics calendar file for appointment
*/
public function download_ics($appointment_id)
{
if (!$appointment_id) {
show_404();
}
// Use existing get_appointment_data method for consistency
$appointment = $this->apm->get_appointment_data($appointment_id);
if (!$appointment) {
show_404();
}
// Check permissions - allow if user is staff, client contact, or lead contact
$has_permission = false;
if (is_staff_logged_in() && staff_can('view', 'appointments')) {
$has_permission = true;
} elseif (is_client_logged_in()) {
// Check if this is the client's appointment through contact_id
if ($appointment['source'] === 'internal' && !empty($appointment['contact_id'])) {
$this->load->model('clients_model');
$contact = $this->clients_model->get_contact($appointment['contact_id']);
if ($contact && $contact->userid == get_client_user_id()) {
$has_permission = true;
}
}
} else {
// For public access (appointment hash links), check if appointment exists
// This allows access via direct appointment hash links
$has_permission = true;
}
if (!$has_permission) {
show_404();
}
// Generate .ics content using helper function
$ics_content = generate_appointment_ics_content($appointment);
// Set headers for .ics download
$filename = 'appointment_' . $appointment_id . '_' . date('Y-m-d', strtotime($appointment['date'])) . '.ics';
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-cache, must-revalidate');
header('Expires: Sat, 26 Jul 1997 05:00:00 GMT');
echo $ics_content;
exit;
}
}