appointments = new AppointmentsRepository(); $this->exceptions = new AvailabilityExceptionsRepository(); $this->availability = new AvailabilityRepository(); $this->customers = new CustomersRepository(); $this->services = new ServicesRepository(); $this->staff = new StaffRepository(); $this->statuses = new StatusesRepository(); } public function register(): void { add_action( 'wp_ajax_abientot_admin_data_bootstrap', array( $this, 'bootstrap' ) ); add_action( 'wp_ajax_abientot_admin_customers_list', array( $this, 'customers_list' ) ); add_action( 'wp_ajax_abientot_admin_bookings_list', array( $this, 'bookings_list' ) ); add_action( 'wp_ajax_abientot_admin_data_delta', array( $this, 'delta' ) ); } public function bootstrap(): void { check_ajax_referer( 'abientot_admin_data', 'nonce' ); $this->require_capability(); $input = Input::post( 'abientot_admin_data', 'nonce' ); $window = $this->window_from_request( $input ); $settings = Settings::values(); $generated = DateTimeHelper::now_utc_sql(); $booking_cursor = $input->int( 'booking_cursor' ); $customer_cursor = $input->int( 'customer_cursor' ); $bookings = $this->appointments->admin_window( $window['start_utc'], $window['end_utc'], 251, $booking_cursor ); $customers = $this->customers->list_for_admin( array( 'after_id' => $customer_cursor, 'id_order' => true ), 251 ); $next = $this->page_cursors( $bookings, $customers, $booking_cursor, $customer_cursor ); if ( ! $input->filled( 'paged_sync' ) ) { $this->complete_legacy_sync( $bookings, $customers, $next, $window ); } wp_send_json_success( array( 'generatedAt' => $generated, 'window' => $window, 'bookings' => $this->booking_payloads( $bookings ), 'customers' => $this->customer_payloads( $customers ), 'references' => array( 'services' => $this->service_payloads( $this->services->list_for_admin() ), 'statuses' => $this->status_payloads( $this->statuses->ordered() ), 'availability' => $this->availability_payload( $window['startDate'], $window['endDate'] ), 'settings' => array( 'currency' => (string) $settings['currency'], 'matchCustomerByEmail' => ! empty( $settings['match_customer_by_email'] ), 'matchCustomerByPhone' => ! empty( $settings['match_customer_by_phone'] ), ), ), 'next' => $next, ) ); } public function delta(): void { check_ajax_referer( 'abientot_admin_data', 'nonce' ); $this->require_capability(); $input = Input::post( 'abientot_admin_data', 'nonce' ); $since = $input->text( 'since' ); $window = $this->window_from_request( $input ); if ( ! $this->valid_utc_datetime( $since ) ) { wp_send_json_error( array( 'message' => __( 'Invalid sync timestamp.', 'abientot' ) ), 400 ); } $generated = DateTimeHelper::now_utc_sql(); $booking_cursor = $input->int( 'booking_cursor' ); $customer_cursor = $input->int( 'customer_cursor' ); $bookings = $this->appointments->admin_changed_since( $since, $window['start_utc'], $window['end_utc'], 251, $booking_cursor ); $customers = $this->customers->admin_changed_since( $since, 251, $customer_cursor ); $next = $this->page_cursors( $bookings, $customers, $booking_cursor, $customer_cursor ); if ( ! $input->filled( 'paged_sync' ) ) { $this->complete_legacy_sync( $bookings, $customers, $next, $window, $since ); } wp_send_json_success( array( 'generatedAt' => $generated, 'next' => $next, 'window' => $window, 'bookings' => $this->booking_payloads( $bookings ), 'customers' => $this->customer_payloads( $customers ), ) ); } /** Older open tabs do not understand continuation tokens. Return a complete batch. */ private function complete_legacy_sync( array &$bookings, array &$customers, ?array &$next, array $window, string $since = '' ): void { while ( $next ) { $booking_cursor = $next['booking_cursor']; $customer_cursor = $next['customer_cursor']; $booking_page = '' === $since ? $this->appointments->admin_window( $window['start_utc'], $window['end_utc'], 251, $booking_cursor ) : $this->appointments->admin_changed_since( $since, $window['start_utc'], $window['end_utc'], 251, $booking_cursor ); $customer_page = '' === $since ? $this->customers->list_for_admin( array( 'after_id' => $customer_cursor, 'id_order' => true ), 251 ) : $this->customers->admin_changed_since( $since, 251, $customer_cursor ); $next = $this->page_cursors( $booking_page, $customer_page, $booking_cursor, $customer_cursor ); $bookings = array_merge( $bookings, $booking_page ); $customers = array_merge( $customers, $customer_page ); } } /** Return continuation cursors; neither stream may be silently truncated. */ private function page_cursors( array &$bookings, array &$customers, int $booking_cursor, int $customer_cursor ): ?array { $more = count( $bookings ) > 250 || count( $customers ) > 250; $bookings = array_slice( $bookings, 0, 250 ); $customers = array_slice( $customers, 0, 250 ); return $more ? array( 'booking_cursor' => $bookings ? (int) end( $bookings )['id'] : $booking_cursor, 'customer_cursor' => $customers ? (int) end( $customers )['id'] : $customer_cursor, ) : null; } /** Customer lists query the whole database independently of cached summaries. */ public function customers_list(): void { check_ajax_referer( 'abientot_admin_data', 'nonce' ); $this->require_capability(); $input = Input::post( 'abientot_admin_data', 'nonce' ); if ( ! current_user_can( 'abientot_manage_customers' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'abientot' ) ), 403 ); } $filters = array( 'search' => $input->text( 's' ) ); $total = $this->customers->count_for_admin( $filters ); $page = min( max( 1, (int) ceil( $total / 25 ) ), max( 1, $input->int( 'list_page', 1 ) ) ); $rows = $this->customers->list_for_admin( $filters, 25, ( $page - 1 ) * 25 ); wp_send_json_success( array( 'customers' => $this->customer_payloads( $rows ), 'total' => $total, 'page' => $page ) ); } /** The booking list is paginated independently of the calendar's cached window. */ public function bookings_list(): void { check_ajax_referer( 'abientot_admin_data', 'nonce' ); $this->require_capability(); $input = Input::post( 'abientot_admin_data', 'nonce' ); if ( ! current_user_can( 'abientot_manage_bookings' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'abientot' ) ), 403 ); } $filters = array(); foreach ( array( 's' => 'search', 'payment_status' => 'payment_status' ) as $field => $key ) { $filters[ $key ] = $input->text( $field ); } foreach ( array( 'status_id', 'service_id' ) as $key ) { $filters[ $key ] = $input->int( $key ); } $filters['staff_id'] = \Abientot\Support\StaffFilter::enabled() ? \Abientot\Support\StaffFilter::request( $input->has( 'staff_id' ) ? $input->text( 'staff_id' ) : 0 ) : 0; $timezone = DateTimeHelper::business_timezone(); foreach ( array( 'date_from', 'date_to' ) as $key ) { $date = $input->text( $key ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) && checkdate( (int) substr( $date, 5, 2 ), (int) substr( $date, 8, 2 ), (int) substr( $date, 0, 4 ) ) ) { $filters[ $key . '_utc' ] = DateTimeHelper::local_to_utc_sql( $date . ( 'date_from' === $key ? ' 00:00:00' : ' 23:59:59' ), $timezone ); if ( 'date_from' === $key && $date >= wp_date( 'Y-m-d', null, $timezone ) ) { $filters['order'] = 'ASC'; } } } $total = $this->appointments->count_for_admin( $filters ); $page = min( max( 1, (int) ceil( $total / 25 ) ), max( 1, $input->int( 'list_page', 1 ) ) ); $rows = $this->appointments->list_for_admin( $filters, 25, ( $page - 1 ) * 25 ); wp_send_json_success( array( 'bookings' => $this->booking_payloads( $rows ), 'total' => $total, 'page' => $page ) ); } /** Capability check for the handlers above, which verify their nonce first. */ private function require_capability(): void { // The payload includes customer contact details and booking notes, so a // reports-only role must not receive this operational PII. if ( ! current_user_can( 'abientot_manage_bookings' ) && ! current_user_can( 'abientot_manage_customers' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'abientot' ) ), 403 ); } } /** * @return array */ private function window_from_request( Input $input ): array { $timezone = DateTimeHelper::business_timezone(); $anchor = $input->text( 'anchor_date', wp_date( 'Y-m-d' ) ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $anchor ) ) { $anchor = wp_date( 'Y-m-d' ); } try { $anchor_date = new DateTimeImmutable( $anchor . ' 00:00:00', $timezone ); } catch ( Exception $exception ) { $anchor_date = new DateTimeImmutable( 'today 00:00:00', $timezone ); } $start = $anchor_date->modify( '-3 months' ); $end = $anchor_date->modify( '+3 months +1 day' ); return array( 'anchorDate' => $anchor_date->format( 'Y-m-d' ), 'startDate' => $start->format( 'Y-m-d' ), 'endDate' => $end->modify( '-1 day' )->format( 'Y-m-d' ), 'start_utc' => $start->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), 'end_utc' => $end->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ), ); } /** * @param array> $rows Booking rows. * @return array> */ private function booking_payloads( array $rows ): array { return array_map( array( $this, 'booking_payload' ), $rows ); } /** * @param array $row Booking row. * @return array */ private function booking_payload( array $row ): array { $timezone = $this->row_timezone( (string) $row['timezone'] ); $start = new DateTimeImmutable( (string) $row['start_utc'], DateTimeHelper::utc_timezone() ); $end = new DateTimeImmutable( (string) $row['end_utc'], DateTimeHelper::utc_timezone() ); $local = $start->setTimezone( $timezone ); $local_end = $end->setTimezone( $timezone ); $id = (int) $row['id']; $can_edit = current_user_can( 'abientot_manage_bookings' ); return array( 'id' => $id, 'customerId' => (int) $row['customer_id'], 'staffId' => (int) $row['staff_id'], 'serviceId' => (int) $row['service_id'], 'bookingKind' => (string) ( $row['booking_kind'] ?? 'service' ), 'resourceCategory' => (string) ( $row['resource_category'] ?? '' ), 'resourceLabel' => (string) ( $row['resource_label'] ?? '' ), 'partySize' => (int) ( $row['party_size'] ?? 0 ), 'locationId' => (int) $row['location_id'], 'locationName' => \Abientot\Support\BookingLocation::show_relationships() ? (string) $row['location_name'] : '', 'staffName' => ( \Abientot\Support\BookingStaff::show_relationships() || apply_filters( 'abientot_admin_staff_filters_enabled', false ) ) ? (string) $row['staff_name'] : '', 'showStaff' => \Abientot\Support\StaffFilter::enabled() || (string) ( $row['booking_kind'] ?? 'service' ) === 'resource', 'rowActions' => BookingsManager::row_actions( $row ), 'statusId' => (int) $row['status_id'], 'startUtc' => (string) $row['start_utc'], 'editStartLocal' => \Abientot\Support\BookingStaff::edit_start( $row ), 'endUtc' => (string) $row['end_utc'], 'updatedAt' => (string) $row['updated_at'], 'timezone' => $timezone->getName(), 'date' => $local->format( 'Y-m-d' ), 'localStart' => $local->format( 'Y-m-d\TH:i' ), 'calendarStart' => $start->setTimezone( DateTimeHelper::business_timezone() )->format( 'Y-m-d H:i:s' ), 'calendarEnd' => $end->setTimezone( DateTimeHelper::business_timezone() )->format( 'Y-m-d H:i:s' ), 'day' => $local->format( 'j' ), 'time' => $local->format( 'H:i' ), 'endTime' => $local_end->format( 'H:i' ), 'timeLabel' => DateTimeHelper::format_utc_datetime( (string) $row['start_utc'], $timezone->getName(), DateTimeHelper::time_display_format() ), 'endTimeLabel' => DateTimeHelper::format_utc_datetime( (string) $row['end_utc'], $timezone->getName(), DateTimeHelper::time_display_format() ), 'localLabel' => DateTimeHelper::format_utc_datetime( (string) $row['start_utc'], $timezone->getName() ), 'customer' => (string) $row['customer_name'], 'firstName' => (string) $row['first_name'], 'lastName' => (string) $row['last_name'], 'customerEmail' => (string) $row['customer_email'], 'customerPhone' => (string) $row['customer_phone'], 'service' => (string) $row['service_name'], 'serviceColor' => \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['service_color'] ) ) ?: '#6b7280', 'status' => (string) $row['status_label'], 'statusSlug' => (string) $row['status_slug'], 'statusColor' => \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['status_color'] ) ) ?: '#172033', 'blocksAvailability' => ! empty( $row['blocks_availability'] ), 'paymentStatus' => (string) $row['payment_status'], 'paymentPlan' => ! empty( $row['has_payment_plan'] ), 'priceCents' => (int) $row['price_cents'], 'currency' => (string) $row['currency'], 'customerNote' => $can_edit ? (string) $row['customer_note'] : '', 'internalNote' => $can_edit ? (string) $row['internal_note'] : '', 'actions' => array( 'edit' => admin_url( 'admin.php?page=abientot-bookings&booking_id=' . $id ), 'statuses' => $this->booking_status_actions( $id ), 'payments' => $this->booking_payment_actions( $id, (string) $row['payment_status'] ), ), ); } /** * @return array */ private function booking_status_actions( int $booking_id ): array { $actions = array(); foreach ( array( 'completed', 'cancelled', 'no_show' ) as $status ) { $actions[ $status ] = html_entity_decode( wp_nonce_url( admin_url( 'admin-post.php?action=abientot_booking_status&booking_id=' . $booking_id . '&status=' . $status ), 'abientot_booking_status' ), ENT_QUOTES, 'UTF-8' ); } return $actions; } /** * @return array */ private function booking_payment_actions( int $booking_id, string $current_status ): array { $actions = array(); foreach ( array( 'paid', 'unpaid', 'not_required' ) as $payment_status ) { if ( $payment_status === $current_status ) { continue; } $actions[ $payment_status ] = html_entity_decode( wp_nonce_url( admin_url( 'admin-post.php?action=abientot_update_booking_payment&booking_id=' . $booking_id . '&payment_status=' . $payment_status ), 'abientot_update_booking_payment' ), ENT_QUOTES, 'UTF-8' ); } return $actions; } /** * @param array> $rows Customer rows. * @return array> */ private function customer_payloads( array $rows ): array { return array_map( function ( array $row ): array { $id = (int) $row['id']; return array( 'id' => $id, 'name' => $this->customer_name( $row ), 'firstName' => (string) $row['first_name'], 'lastName' => (string) $row['last_name'], 'email' => (string) $row['email'], 'phone' => (string) $row['phone'], 'bookingCount' => (int) $row['booking_count'], 'totalSpentCents' => (int) $row['total_spent_cents'], 'averageSpendCents' => (int) $row['average_spend_cents'], 'cancelledCount' => (int) $row['cancelled_count'], 'noShowCount' => (int) $row['no_show_count'], 'updatedAt' => (string) $row['updated_at'], 'actions' => array( 'view' => admin_url( 'admin.php?page=abientot-customers&customer_id=' . $id ), 'createBooking' => admin_url( 'admin.php?page=abientot-bookings&customer_id=' . $id ), ), ); }, $rows ); } /** * @param array> $rows Service rows. * @return array> */ private function service_payloads( array $rows ): array { return array_map( static function ( array $row ): array { return array( 'id' => (int) $row['id'], 'name' => (string) $row['name'], 'color' => \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['color'] ) ) ?: '#172033', ); }, $rows ); } /** * @param array> $rows Status rows. * @return array> */ private function status_payloads( array $rows ): array { return array_map( static function ( array $row ): array { return array( 'id' => (int) $row['id'], 'slug' => (string) $row['slug'], 'label' => (string) $row['label'], 'color' => \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['color'] ) ) ?: '#172033', ); }, $rows ); } /** * @return array>> */ private function availability_payload( string $start_date, string $end_date ): array { return ( new CalendarManager() )->availability_payload( $start_date, $end_date ); } /** * @param array> $rows Availability rows. * @return array> */ private function weekly_availability_payloads( array $rows ): array { $payloads = array(); foreach ( $rows as $row ) { if ( ! in_array( (string) $row['rule_type'], array( 'working', 'break' ), true ) ) { continue; } $payloads[] = array( 'weekday' => (int) $row['weekday'], 'type' => (string) $row['rule_type'], 'start' => substr( (string) $row['start_time'], 0, 5 ), 'end' => substr( (string) $row['end_time'], 0, 5 ), ); } return $payloads; } /** * @param array> $rows Exception rows. * @return array> */ private function exception_payloads( array $rows ): array { $payloads = array(); foreach ( $rows as $row ) { $payloads[] = array( 'date' => (string) $row['exception_date'], 'type' => (string) $row['exception_type'], 'start' => empty( $row['start_time'] ) ? '' : substr( (string) $row['start_time'], 0, 5 ), 'end' => empty( $row['end_time'] ) ? '' : substr( (string) $row['end_time'], 0, 5 ), ); } return $payloads; } private function customer_name( array $customer ): string { $name = trim( (string) ( $customer['display_name'] ?? '' ) ); return '' !== $name ? $name : __( 'Unnamed customer', 'abientot' ); } private function row_timezone( string $timezone ): DateTimeZone { try { return new DateTimeZone( $timezone ); } catch ( Exception $exception ) { return DateTimeHelper::business_timezone(); } } private function valid_utc_datetime( string $datetime ): bool { return (bool) preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $datetime ); } }