, amelia_after_appointment_status_updated, amelia_after_appointment_updated, * amelia_after_booking_rescheduled, ...) tell us which bookings changed. Their payloads differ between * hooks and do not always carry the customer, so the booking itself is read from Amelia's tables * (amelia_customer_bookings, amelia_appointments, amelia_users, amelia_services). Appointments only; * Amelia "events" (tickets) are not covered. * * @package Smsportal */ namespace Smsportal\Integrations; use Smsportal\Events; use Smsportal\Phone; use Smsportal\Queue; use Smsportal\Retention; use Smsportal\Rules; use Smsportal\Variables; defined( 'ABSPATH' ) || exit; /** * Amelia appointments as SMS events. Reminders are planned when the booking is made (and again when it is * moved), one job per reminder rule; the job checks the booking again before sending. An hourly sweep covers * what those jobs miss: bookings made before the rule existed, moves without a hook, the site being down. */ class Booking { const GROUP = 'Amelia'; const EVENT_CREATED = 'amelia.booking_created'; const EVENT_STATUS = 'amelia.booking_status.'; const EVENT_REMINDER = 'amelia.booking_reminder'; const OBJECT_TYPE = 'amelia_booking'; const REMINDER_HOOK = 'smsportal_amelia_reminder'; /** * Amelia booking statuses. */ const STATUSES = array( 'approved', 'pending', 'canceled', 'rejected', 'no-show' ); /** * Statuses that get a reminder. */ const REMINDER_STATUSES = array( 'approved', 'pending' ); /** * Whether Amelia is active. * * @return bool */ public static function available(): bool { return defined( 'AMELIA_VERSION' ); } /** * Register hooks. * * @return void */ public static function register(): void { add_filter( 'smsportal_register_events', array( self::class, 'events' ) ); add_action( self::REMINDER_HOOK, array( self::class, 'send_reminder' ), 10, 3 ); add_action( Retention::HOURLY_HOOK, array( self::class, 'sweep_reminders' ) ); add_filter( 'smsportal_privacy_phones', array( self::class, 'privacy_phones' ), 10, 2 ); // New bookings: booking form (front end) and the admin calendar. add_action( 'amelia_after_booking_added', array( self::class, 'on_booking_added' ), 20 ); add_action( 'amelia_after_appointment_added', array( self::class, 'on_appointment_added' ), 20 ); // Status of one booking: admin/employee panel (amelia_after_booking_, also the customer's // cancellation) and the approve/reject links from Amelia's e-mails. foreach ( self::STATUSES as $status ) { add_action( 'amelia_after_booking_' . $status, array( self::class, 'on_booking_status' ), 20 ); } add_action( 'amelia_after_booking_approved_link', array( self::class, 'on_booking_link' ), 20 ); add_action( 'amelia_after_booking_rejected_link', array( self::class, 'on_booking_link' ), 20 ); // Status of a whole appointment, and edits in the appointment dialog. add_action( 'amelia_after_appointment_status_updated', array( self::class, 'on_appointment_status' ), 20 ); add_action( 'amelia_after_appointment_updated', array( self::class, 'on_appointment_updated' ), 20, 2 ); add_action( 'amelia_after_booking_rescheduled', array( self::class, 'on_rescheduled' ), 20, 2 ); } /** * Add the events. * * @param mixed $events Events so far. * @return array> */ public static function events( $events ): array { $events = is_array( $events ) ? $events : array(); $events[] = self::event( self::EVENT_CREATED, __( 'Amelia: new booking', '85pl-android-sms-api' ), '' ); foreach ( self::statuses() as $status => $name ) { /* translators: %s: booking status, e.g. "Canceled". */ $events[] = self::event( self::EVENT_STATUS . $status, sprintf( __( 'Amelia: booking status changed to "%s"', '85pl-android-sms-api' ), $name ), $status ); } $events[] = self::event( self::EVENT_REMINDER, __( 'Amelia: reminder before the appointment', '85pl-android-sms-api' ), '' ); return $events; } /** * Hook amelia_after_booking_added: booking form (data: type, appointment, booking, recurring). * * @param mixed $data Booking result. * @return void */ public static function on_booking_added( $data ): void { if ( ! is_array( $data ) || 'appointment' !== ( $data['type'] ?? '' ) ) { return; } $ids = array( self::id_of( $data['booking'] ?? null ) ); foreach ( (array) ( $data['recurring'] ?? array() ) as $recurring ) { $ids[] = is_array( $recurring ) ? self::id_of( $recurring['booking'] ?? null ) : 0; } self::created( $ids ); } /** * Hook amelia_after_appointment_added: appointment made in the admin. * * @param mixed $appointment Appointment with its bookings. * @return void */ public static function on_appointment_added( $appointment ): void { self::created( self::booking_ids( $appointment ) ); } /** * Hook amelia_after_booking_: data with the booking. * * @param mixed $data Booking data. * @return void */ public static function on_booking_status( $data ): void { self::status_changed( array( self::id_of( is_array( $data ) ? ( $data['booking'] ?? null ) : null ) ) ); } /** * Hook amelia_after_booking_approved_link / _rejected_link: the booking. * * @param mixed $booking Booking. * @return void */ public static function on_booking_link( $booking ): void { self::status_changed( array( self::id_of( $booking ) ) ); } /** * Hook amelia_after_appointment_status_updated: every booking of the appointment gets the new status. * * @param mixed $appointment Appointment with its bookings. * @return void */ public static function on_appointment_status( $appointment ): void { self::status_changed( self::booking_ids( $appointment ) ); } /** * Hook amelia_after_appointment_updated: added customers, changed statuses, a new time. * * @param mixed $appointment Appointment after the change. * @param mixed $old_appointment Appointment before. * @return void */ public static function on_appointment_updated( $appointment, $old_appointment = null ): void { $old = self::booking_statuses( $old_appointment ); $new = self::booking_statuses( $appointment ); $moved = is_array( $appointment ) && is_array( $old_appointment ) && ( $appointment['bookingStart'] ?? '' ) !== ( $old_appointment['bookingStart'] ?? '' ); $added = array_keys( array_diff_key( $new, $old ) ); $changed = array(); foreach ( $new as $id => $status ) { if ( isset( $old[ $id ] ) && $old[ $id ] !== $status ) { $changed[] = $id; } } self::created( $added ); self::status_changed( $changed ); if ( $moved ) { self::plan_reminders( array_keys( array_intersect_key( $new, $old ) ) ); } } /** * Hook amelia_after_booking_rescheduled: plan reminders for the new time. * * @param mixed $old_appointment Appointment before the move (with its bookings). * @param mixed $booking The moved booking, or null when the whole appointment moved. * @return void */ public static function on_rescheduled( $old_appointment, $booking = null ): void { $id = self::id_of( $booking ); self::plan_reminders( $id > 0 ? array( $id ) : self::booking_ids( $old_appointment ) ); } /** * Job: reminder of one rule for one booking. Skipped when the booking is gone, canceled or moved * (a move plans a new job). * * @param int|string $booking_id Booking ID. * @param int|string $rule_id Rule ID. * @param int|string $start Appointment start the job was planned for (Unix time). * @return void */ public static function send_reminder( $booking_id, $rule_id, $start ): void { try { $context = self::context( absint( $booking_id ) ); if ( null === $context || (int) $context['start'] !== (int) $start || ! in_array( $context['status_code'], self::REMINDER_STATUSES, true ) ) { return; } // Many appointments at the same hour remind together: no per-rule hourly cap (country and // per-number limits still apply). Events::fire_rule( self::EVENT_REMINDER, absint( $rule_id ), $context, (string) $context['start'], false ); } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_REMINDER ); } } /** * Hourly: for each enabled reminder rule, bookings that start within its hours_before plus an hour. A * reminder due within the hour gets its job (unless one waits); a reminder whose time has passed is sent * now, unless the booking was made after that time (as at booking time, it then gets none). The SMS's * de-duplication (booking, rule, start) keeps a reminder that already went from going again. * * @return void */ public static function sweep_reminders(): void { try { $rules = Rules::enabled_for_event( self::EVENT_REMINDER ); if ( array() === $rules ) { return; } $now = time(); $hours = max( array_map( 'intval', wp_list_pluck( $rules, 'hours_before' ) ) ); foreach ( self::upcoming( $now, $now + ( $hours + 1 ) * HOUR_IN_SECONDS ) as $booking ) { foreach ( $rules as $rule ) { self::sweep_one( $booking, $rule, $now ); } } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_REMINDER ); } } /** * The sweep for one booking and one rule. * * @param array{id:int,start:int,created:int|null} $booking Booking. * @param array $rule Reminder rule. * @param int $now Unix time. * @return void */ private static function sweep_one( array $booking, array $rule, int $now ): void { $before = (int) $rule['hours_before'] * HOUR_IN_SECONDS; if ( $booking['start'] > $now + $before + HOUR_IN_SECONDS ) { return; } $when = $booking['start'] - $before; $args = array( $booking['id'], (int) $rule['id'], $booking['start'] ); if ( $when > $now ) { if ( ! Queue::is_hook_scheduled( self::REMINDER_HOOK, $args ) ) { Queue::schedule_hook( self::REMINDER_HOOK, $args, $when ); } return; } if ( null === $booking['created'] || $booking['created'] < $when ) { self::send_reminder( ...$args ); } } /** * Appointment bookings (approved or pending) starting in a time range. * * @param int $from Unix time. * @param int $to Unix time. * @return array */ private static function upcoming( int $from, int $to ): array { global $wpdb; $suppress = $wpdb->suppress_errors( true ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Amelia's tables, prefix from $wpdb; the sweep needs the current state. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT cb.id, a.bookingStart, cb.created FROM {$wpdb->prefix}amelia_customer_bookings cb INNER JOIN {$wpdb->prefix}amelia_appointments a ON a.id = cb.appointmentId WHERE cb.status IN ( 'approved', 'pending' ) AND a.bookingStart > %s AND a.bookingStart <= %s", gmdate( 'Y-m-d H:i:s', $from ), gmdate( 'Y-m-d H:i:s', $to ) ), ARRAY_A ); // phpcs:enable $wpdb->suppress_errors( $suppress ); $result = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $created = empty( $row['created'] ) ? false : strtotime( $row['created'] . ' UTC' ); $result[] = array( 'id' => (int) $row['id'], 'start' => (int) strtotime( $row['bookingStart'] . ' UTC' ), 'created' => false === $created ? null : $created, ); } return $result; } /** * Numbers of a person for the privacy tools: Amelia customers with that e-mail address. * * @param mixed $phones Numbers so far. * @param string $email E-mail address. * @return array */ public static function privacy_phones( $phones, $email ): array { global $wpdb; $phones = is_array( $phones ) ? $phones : array(); $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Amelia's table, a rare privacy request. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT phone, countryPhoneIso FROM {$wpdb->prefix}amelia_users WHERE type = 'customer' AND email = %s", (string) $email ), ARRAY_A ); $wpdb->suppress_errors( $suppress ); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $phone = (string) $row['phone']; if ( '' === trim( $phone ) ) { continue; } $country = Phone::calling_code( (string) $row['countryPhoneIso'] ); $phones[] = Phone::normalize( $phone, null !== $country ? $country : Phone::default_country_code() ); } return array_values( array_filter( $phones, 'is_string' ) ); } /** * Template variables and the customer's number of a booking, read from Amelia's tables. * * @param int $booking_id Booking ID. * @return array|null Null when there is no such appointment booking. */ public static function context( int $booking_id ): ?array { $row = self::row( $booking_id ); if ( null === $row ) { return null; } $start = (int) strtotime( $row['bookingStart'] . ' UTC' ); $status = (string) $row['status']; $statuses = self::statuses(); $country = Phone::calling_code( (string) $row['countryPhoneIso'] ); $phone = (string) $row['phone']; return array( 'booking_id' => (int) $row['id'], 'imie' => (string) $row['firstName'], 'nazwisko' => (string) $row['lastName'], 'usluga' => (string) $row['service'], 'pracownik' => trim( $row['providerFirstName'] . ' ' . $row['providerLastName'] ), 'data' => wp_date( (string) get_option( 'date_format' ), $start ), 'godzina' => wp_date( (string) get_option( 'time_format' ), $start ), 'status' => $statuses[ $status ] ?? $status, 'numer_rezerwacji' => (string) $row['id'], 'nazwa_firmy' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), 'telefon' => '' === trim( $phone ) ? null : Phone::normalize( $phone, null !== $country ? $country : Phone::default_country_code() ), 'status_code' => $status, 'start' => $start, ); } /** * Fire "new booking" and plan its reminders. * * @param array $ids Booking IDs. * @return void */ private static function created( array $ids ): void { foreach ( self::valid_ids( $ids ) as $id ) { try { $context = self::context( $id ); if ( null !== $context ) { Events::fire( self::EVENT_CREATED, $context ); } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_CREATED ); } } self::plan_reminders( $ids ); } /** * Fire the status event for the bookings' current status. The status is the dedupe suffix: the same * status reported by two hooks sends one SMS. * * @param array $ids Booking IDs. * @return void */ private static function status_changed( array $ids ): void { foreach ( self::valid_ids( $ids ) as $id ) { try { $context = self::context( $id ); if ( null !== $context ) { Events::fire( self::EVENT_STATUS . $context['status_code'], $context, $context['status_code'] ); } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_STATUS ); } } } /** * Plan a reminder job per enabled reminder rule (hours_before before the start; none when that time * has passed). * * @param array $ids Booking IDs. * @return void */ private static function plan_reminders( array $ids ): void { $ids = self::valid_ids( $ids ); if ( array() === $ids ) { return; } try { $rules = Rules::enabled_for_event( self::EVENT_REMINDER ); foreach ( $ids as $id ) { $context = array() === $rules ? null : self::context( $id ); if ( null === $context ) { continue; } foreach ( $rules as $rule ) { $when = (int) $context['start'] - (int) $rule['hours_before'] * HOUR_IN_SECONDS; if ( $when > time() ) { Queue::schedule_hook( self::REMINDER_HOOK, array( $id, (int) $rule['id'], (int) $context['start'] ), $when ); } } } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_REMINDER ); } } /** * One event definition. * * @param string $id Event ID. * @param string $label Label. * @param string $status Status the event is about ('' = any). * @return array */ private static function event( string $id, string $label, string $status ): array { return array( 'id' => $id, 'label' => $label, 'group' => self::GROUP, 'icon' => SMSPORTAL_URL_BASE . 'assets/integrations/amelia.png', 'variables' => Variables::for_editor( array( 'imie' => __( 'First name', '85pl-android-sms-api' ), 'nazwisko' => __( 'Last name', '85pl-android-sms-api' ), 'usluga' => __( 'Service', '85pl-android-sms-api' ), 'pracownik' => __( 'Employee', '85pl-android-sms-api' ), 'data' => __( 'Date', '85pl-android-sms-api' ), 'godzina' => __( 'Time', '85pl-android-sms-api' ), 'status' => __( 'Booking status', '85pl-android-sms-api' ), 'numer_rezerwacji' => __( 'Booking number', '85pl-android-sms-api' ), 'nazwa_firmy' => __( 'Site name', '85pl-android-sms-api' ), ) ), 'customer_phone' => static function ( array $context ) { return isset( $context['telefon'] ) && is_string( $context['telefon'] ) ? $context['telefon'] : null; }, 'object' => static function ( array $context ): array { return array( 'type' => self::OBJECT_TYPE, 'id' => (string) ( $context['booking_id'] ?? '' ), 'url' => admin_url( 'admin.php?page=wpamelia-appointments' ), ); }, 'sample' => static function () use ( $status ) { return self::sample( $status ); }, 'rule_fields' => self::EVENT_REMINDER === $id ? array( 'hours_before' ) : array(), ); } /** * Context of the latest appointment booking (with the given status), for the rule preview. * * @param string $status Status, '' = any. * @return array|null */ private static function sample( string $status ): ?array { global $wpdb; $suppress = $wpdb->suppress_errors( true ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Amelia's table, prefix from $wpdb. $id = '' === $status ? $wpdb->get_var( "SELECT MAX(id) FROM {$wpdb->prefix}amelia_customer_bookings WHERE appointmentId IS NOT NULL" ) : $wpdb->get_var( $wpdb->prepare( "SELECT MAX(id) FROM {$wpdb->prefix}amelia_customer_bookings WHERE appointmentId IS NOT NULL AND status = %s", $status ) ); // phpcs:enable $wpdb->suppress_errors( $suppress ); return null === $id ? null : self::context( (int) $id ); } /** * Booking row joined with its appointment, customer, service and employee. * * @param int $booking_id Booking ID. * @return array|null */ private static function row( int $booking_id ): ?array { global $wpdb; // Amelia's schema is not ours: a missing table or column must not print errors on the page. $suppress = $wpdb->suppress_errors( true ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Amelia's tables, prefix from $wpdb; the reminder needs the current state. $row = $wpdb->get_row( $wpdb->prepare( "SELECT cb.id, cb.status, a.bookingStart, c.firstName, c.lastName, c.phone, c.countryPhoneIso, s.name AS service, p.firstName AS providerFirstName, p.lastName AS providerLastName FROM {$wpdb->prefix}amelia_customer_bookings cb INNER JOIN {$wpdb->prefix}amelia_appointments a ON a.id = cb.appointmentId INNER JOIN {$wpdb->prefix}amelia_users c ON c.id = cb.customerId LEFT JOIN {$wpdb->prefix}amelia_services s ON s.id = a.serviceId LEFT JOIN {$wpdb->prefix}amelia_users p ON p.id = a.providerId WHERE cb.id = %d", $booking_id ), ARRAY_A ); // phpcs:enable $wpdb->suppress_errors( $suppress ); return is_array( $row ) ? $row : null; } /** * Status names. * * @return array */ private static function statuses(): array { return array( 'approved' => __( 'Approved', '85pl-android-sms-api' ), 'pending' => __( 'Pending', '85pl-android-sms-api' ), 'canceled' => __( 'Canceled', '85pl-android-sms-api' ), 'rejected' => __( 'Rejected', '85pl-android-sms-api' ), 'no-show' => __( 'No-show', '85pl-android-sms-api' ), ); } /** * Booking IDs of an appointment payload. * * @param mixed $appointment Appointment. * @return array */ private static function booking_ids( $appointment ): array { return array_keys( self::booking_statuses( $appointment ) ); } /** * Booking ID => status of an appointment payload. * * @param mixed $appointment Appointment. * @return array */ private static function booking_statuses( $appointment ): array { $result = array(); $list = is_array( $appointment ) ? ( $appointment['bookings'] ?? array() ) : array(); foreach ( is_array( $list ) ? $list : array() as $booking ) { $id = self::id_of( $booking ); if ( $id > 0 ) { $result[ $id ] = is_array( $booking ) ? (string) ( $booking['status'] ?? '' ) : ''; } } return $result; } /** * ID of a booking payload. * * @param mixed $booking Booking. * @return int */ private static function id_of( $booking ): int { return is_array( $booking ) && isset( $booking['id'] ) && is_scalar( $booking['id'] ) ? absint( $booking['id'] ) : 0; } /** * Positive, unique IDs. * * @param array $ids IDs. * @return array */ private static function valid_ids( array $ids ): array { return array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ); } }