# booktics/1.0.27/base/models/appointment-model.php

Booktics – Appointment Booking Calendar for Service Businesses, version 1.0.27. 469 lines.

- Page: https://pluginprobe.com/plugins/booktics/1.0.27/code/base/models/appointment-model.php
- Raw: https://pluginprobe.com/plugins/booktics/1.0.27/raw/base/models/appointment-model.php
- Modified: 2026-09-28T15:21:16+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/1.0.27/code/base/models/appointment-model.php#L10-L20`.

```php
<?php
/**
 * Appoint Model
 *
 * @package Booktics/Models
 */

namespace Booktics\Models;

use Booktics\Abstracts\Booktics_Database;
use Booktics\Abstracts\Post_Model;
use Booktics\Models\Service_Model;
use Booktics\Order\Services\Order_Key;
use Exception;

/**
 * AppointmentModel class
 */
class Appointment_Model extends Post_Model {
    /**
     * Store appointment data
     *
     * @var array
     */
    protected $fillable = array(
        'id'                     => '',
        'title'                  => '',
        'content'                => '',
        'start_time'             => '',
        'end_time'               => '',
        'date'                   => '',
        'buffer_before'          => '',
        'buffer_after'           => '',
        'duration'               => '',
        'status'                 => '',
        'payment_status'         => '',
        'customer_id'            => '',
        'customer_name'          => '',
        'team_member_id'         => '',
        'service_id'             => '',
        'additional_duration_id' => '',
        'group_booking'          => array(),
        'order_id'               => '',
        'quantity'               => 0,
        'price'                  => 0,
        'subtotal'               => 0,
        'total'                  => 0,
        'note'                   => '',
        'source'                 => '',
        'location_id'            => '',
        'location'               => array(),
        'extra_services'         => array(),
        'security_token'         => '',
        'total_attendees'        => 1,
        'timezone'               => '',
        'cancel_note'            => '',
    );

    /**
     * Accepted post statuses for appointments
     *
     * @var array
     */
    public static $accepted_statuses = array(
        'pending',
        'active',
        'cancelled',
        'canceled',
        'completed',
        'approved',
        'not_now',
    );

    /**
     * Get post type
     *
     * @return  string Post type name
     */
    public function get_post_type() {
        return 'booktics-appointment';
    }

    /**
     * Get accepted statuses
     *
     * @return  array Accepted statuses
     */
    public static function get_accepted_statuses() {
        return self::$accepted_statuses;
    }

    /**
     * Specify data which should be serialized to JSON.
     *
     * @return array
     */
    public function jsonSerialize(): array {
        $data = $this->to_array();

        $service     = Service_Model::find( $this->service_id );
        $db          = new Booktics_Database();
        $customer    = ( new Guest_Model( $db ) )->find( array( 'id' => $this->customer_id ) );
        $team_member = ( new Team_Member_Model() )->find( $this->team_member_id );

        $appointment_data = array(
            'service'     => $service,
            'team_member' => $team_member,
            'customer'    => $customer,
        );

        return array_merge( $data, $appointment_data );
    }

    /**
     * Get appointments for a specific date and service/team member
     *
     * @param string $date
     * @param int $team_member_id
     *
     * @return array
     * @throws Exception
     */
    public function get_appointments_for_slot( string $date, int $team_member_id ): array {
        $query = new \WP_Query( self::slot_query_args( $date, $team_member_id ) );

        return array_map(
            function ( $post ) {
                return new static( $post );
            }, $query->posts
        );
    }

    /**
     * Get appointment IDs for a date and team member, with their meta primed.
     *
     * The same query as get_appointments_for_slot() without hydrating a model
     * per row. Hydration runs load_attributes(), which fires an extra order
     * lookup for every appointment, so a caller that only needs a few meta
     * values pays N+1 queries for nothing. One update_meta_cache() call here
     * makes every later get_post_meta() a cache read.
     *
     * @param string $date           Y-m-d.
     * @param int    $team_member_id Team member user ID.
     * @param array  $statuses       Post statuses to count, empty for the site setting.
     *
     * @return int[] Appointment post IDs.
     */
    public static function get_appointment_ids_for_slot( string $date, int $team_member_id, array $statuses = array() ): array {
        $args = self::slot_query_args( $date, $team_member_id );

        if ( $statuses ) {
            $args['post_status'] = $statuses;
        }

        $args['fields']        = 'ids';
        $args['no_found_rows'] = true;

        $ids = array_map( 'intval', ( new \WP_Query( $args ) )->posts );

        if ( $ids ) {
            update_meta_cache( 'post', $ids );
        }

        return $ids;
    }

    /**
     * Build the shared query args for one date and team member.
     *
     * @param string $date           Y-m-d.
     * @param int    $team_member_id Team member user ID.
     *
     * @return array
     */
    private static function slot_query_args( string $date, int $team_member_id ): array {
        return array(
            'post_type'      => 'booktics-appointment',
            'post_status'    => booktics_get_option( 'appointment_statuses_for_blocked_slot', array( 'publish', 'active', 'pending' ) ),
            'posts_per_page' => - 1,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
            'meta_query'     => array(
                'relation' => 'AND',
                array(
                    'key'     => 'date',
                    'value'   => $date,
                    'compare' => '=',
                ),
                array(
                    'key'     => 'team_member_id',
                    'value'   => $team_member_id,
                    'compare' => '=',
                ),
            ),
        );
    }

    /**
     * Create an appointment and give it a security token straight away.
     *
     * The token is what lets a customer reschedule or cancel their own
     * booking. It used to appear only as a side effect of building the
     * confirmation email links, so a booking paid for up front, which sends
     * no email at create time, was handed back to the browser without one and
     * its own Reschedule and Cancel buttons were refused.
     *
     * @param array $attributes Collection of attributes.
     *
     * @return mixed The created model, or whatever the parent returned on failure.
     */
    public static function create( array $attributes ) {
        $appointment = parent::create( $attributes );

        if ( $appointment instanceof self ) {
            $appointment->add_security_token_if_not_exists();
        }

        return $appointment;
    }

    /**
     * Override load_attributes to include payment status from orders
     *
     * @return void
     */
    protected function load_attributes() {
        parent::load_attributes();

        if ( $this->order_id ) {
            $db          = new Booktics_Database();
            $order_model = ( new Order_Model( $db ) )->find( array( 'id' => $this->order_id ) );

            $this->attributes['payment_status'] = $order_model->payment_status ?? 'pending';
        } else {
            $this->attributes['payment_status'] = 'pending';
        }
    }

    /**
     * Get appointments count by order ID
     *
     * @param int $order_id Order ID
     *
     * @return int Number of appointments
     */
    public function get_appointments_count_by_order_id( $order_id ) {
        $args = array(
            'post_type'      => $this->get_post_type(),
            'post_status'    => array( 'publish', 'active', 'approved', 'completed', 'pending' ),
            'posts_per_page' => - 1,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
            'meta_query'     => array(
                array(
                    'key'     => 'order_id',
                    'value'   => $order_id,
                    'compare' => '=',
                ),
            ),
        );

        $query = new \WP_Query( $args );

        return $query->found_posts;
    }

    /**
     * Get all appointments attached to an order
     *
     * @param int $order_id Order ID
     *
     * @return array<static>
     */
    public function get_appointments_by_order_id( $order_id ) {
        $args = array(
            'post_type'      => $this->get_post_type(),
            'post_status'    => static::$accepted_statuses,
            'posts_per_page' => - 1,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by order.
            'meta_query'     => array(
                array(
                    'key'     => 'order_id',
                    'value'   => $order_id,
                    'compare' => '=',
                ),
            ),
        );

        $query = new \WP_Query( $args );

        return array_map(
            function ( $post ) {
                return new static( $post );
            }, $query->posts
        );
    }

    /**
     * Get appointments count by team member ID
     *
     * @param int $team_member_id Team member ID
     *
     * @return int Number of appointments
     */
    public function get_appointments_count_by_team_member_id( $team_member_id ) {
        $args = array(
            'post_type'      => $this->get_post_type(),
            'post_status'    => array( 'publish', 'active', 'pending' ),
            'posts_per_page' => - 1,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
            'meta_query'     => array(
                array(
                    'key'     => 'team_member_id',
                    'value'   => $team_member_id,
                    'compare' => '=',
                ),
            ),
        );

        $query = new \WP_Query( $args );

        return $query->found_posts;
    }

    /**
     * Get reschedule link
     *
     * @return string Reschedule link
     */
    public function get_reschedule_link() {
        if ( ! booktics_get_option( 'allow_customer_rescheduling', false ) ) {
            return '';
        }

        $this->add_security_token_if_not_exists();

        $service_permalink = $this->get_associated_service_permalink();

        $reschedule_url = add_query_arg( [
            'service_id'        => $this->service_id,
            'appointment_id'    => $this->id,
            'booking_action'    => 'reschedule',
            'appointment_token' => $this->security_token,
            'order_id'          => $this->order_id,
            'order_key'         => $this->get_order_view_key(),
            'team_member_id'    => $this->team_member_id,
        ], $service_permalink );

        return esc_url( $reschedule_url );
    }

    /**
     * Get cancel link
     *
     * @return string Cancel link
     */
    public function get_cancel_link() {
        if ( ! booktics_get_option( 'allow_customer_cancellation', false ) ) {
            return '';
        }

        $this->add_security_token_if_not_exists();

        $service_permalink = $this->get_associated_service_permalink();

        $cancel_url = add_query_arg( [
            'service_id'        => $this->service_id,
            'appointment_id'    => $this->id,
            'booking_action'    => 'cancel',
            'appointment_token' => $this->security_token,
            'order_id'          => $this->order_id,
            'order_key'         => $this->get_order_view_key(),
            'team_member_id'    => $this->team_member_id,
        ], $service_permalink );

        return esc_url( $cancel_url );
    }

    /**
     * Key that lets the customer open their own order without an account.
     *
     * The booking screen has to load the order before it can show the
     * reschedule or cancel dialog, and that request is refused for a visitor
     * with no session unless it carries this key. Someone following a link
     * from their confirmation email is exactly that visitor, so the key
     * travels in the link.
     *
     * @return string Empty when the appointment has no order.
     */
    private function get_order_view_key() {
        if ( ! $this->order_id ) {
            return '';
        }

        $db    = new Booktics_Database();
        $order = ( new Order_Model( $db ) )->find( array( 'id' => $this->order_id ) );

        if ( ! $order ) {
            return '';
        }

        return Order_Key::make( (int) $this->order_id, (string) $order->order_no );
    }

    /**
     * Get associated service permalink
     *
     * @return string Service permalink
     */
    private function get_associated_service_permalink() {
        if ( ! $this->service_id ) {
            return '';
        }

        $service = Service_Model::find( $this->service_id );

        return $service->link ?? '';
    }

    /**
     * Generate a security token for the appointment
     * This generated token is used to verify access to the appointment rescheduling and cancellation
     *
     * @return string Security token
     */
    public function generate_security_token() {
        // This token is the only thing standing between a stranger and
        // someone else's booking, and nothing rate limits the REST route, so
        // it has to be too wide to walk through. Tokens already issued keep
        // working; hash_equals() does not care how long they are.
        $token = bin2hex( random_bytes( 16 ) );

        return $token;
    }

    /**
     * Check if the security token exists, if not generate and save it to the post meta.
     * This token is used to verify access to the appointment rescheduling and cancellation.
     * 
     * @return void
     */
    public function add_security_token_if_not_exists() {
        // Post_Model serves its fields through __get() and defines no
        // __isset(), so empty( $this->security_token ) is true even when a
        // token is stored. That minted a fresh token on every call, and since
        // the confirmation email builds the reschedule link and then the
        // cancel link, the reschedule link was already dead by the time the
        // mail went out. Read the value into a local variable first.
        $token = (string) $this->security_token;

        if ( '' !== $token ) {
            return;
        }

        $this->security_token = $this->generate_security_token();

        // Store the one field directly. save() runs wp_update_post() over the
        // whole row, which is a lot of work for a single meta value and makes
        // WordPress check publish_post against the appointment post type. In
        // WP-CLI, cron and the test bootstrap that type is not registered yet,
        // so the check raises a _doing_it_wrong() notice.
        if ( $this->id ) {
            update_post_meta( (int) $this->id, 'security_token', $this->security_token );

            return;
        }

        $this->save();
    }
}

```
