( $booking, $calendarEvent ): status changes by the host (admin * screen, MCP), the guest's cancellation, the five-minute scheduler (completed, auto_cancelled). * - fluent_booking/after_booking_rescheduled ( $booking, $previousBooking, $calendarEvent ). * Statuses: scheduled, pending (awaiting the host's confirmation), completed, cancelled, rejected, no_show; * "reserved" (unpaid checkout) is not a booking yet. * * The booking is read again through FluentBooking's own model, so a hook's payload is only its ID. Dates * are shown in the site's time zone and date/time format, as for Amelia (the guest's own time zone is not * known to every rule and the site's is what the business talks in). * * @package Smsportal */ namespace Smsportal\Integrations; use FluentBooking\App\Models\Booking as Model; use Smsportal\Events; use Smsportal\Phone; use Smsportal\Queue; use Smsportal\Retention; use Smsportal\Rules; use Smsportal\Variables; defined( 'ABSPATH' ) || exit; /** * FluentBooking bookings as SMS events. One booking row is one guest; the SMS is per reservation (see * reservation_key()), so guests added by a booker do not multiply the booker's SMS. Reminders work as for * Amelia: one job per reminder rule planned when the booking is made or moved, checked again before sending, * plus an hourly sweep. */ class FluentBooking { const GROUP = 'FluentBooking'; const EVENT_CREATED = 'fluentbooking.booking_created'; const EVENT_STATUS = 'fluentbooking.booking_status.'; const EVENT_RESCHEDULED = 'fluentbooking.booking_rescheduled'; const EVENT_REMINDER = 'fluentbooking.booking_reminder'; const OBJECT_TYPE = 'fluentbooking_booking'; const REMINDER_HOOK = 'smsportal_fluentbooking_reminder'; /** * Statuses a booking can change to (each has an event). */ const STATUSES = array( 'scheduled', 'completed', 'cancelled', 'rejected', 'no_show' ); /** * Statuses of a booking made through the form. */ const NEW_STATUSES = array( 'scheduled', 'pending' ); /** * Statuses that get a reminder. */ const REMINDER_STATUSES = array( 'scheduled', 'pending' ); /** * Bookings confirmed by the host in this request: their after_booking_scheduled is not a new booking. * * @var array */ private static $confirmed = array(); /** * Whether FluentBooking is active. * * @return bool */ public static function available(): bool { return defined( 'FLUENT_BOOKING_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 ); foreach ( self::NEW_STATUSES as $status ) { add_action( 'fluent_booking/after_booking_' . $status, array( self::class, 'on_booking_created' ), 20 ); } foreach ( array_merge( self::STATUSES, array( 'auto_cancelled' ) ) as $status ) { add_action( 'fluent_booking/booking_schedule_' . $status, array( self::class, 'on_status' ), 20 ); } add_action( 'fluent_booking/after_booking_rescheduled', array( self::class, 'on_rescheduled' ), 20 ); } /** * 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, __( 'FluentBooking: new booking', '85pl-android-sms-api' ), '' ); $statuses = self::statuses(); foreach ( self::STATUSES as $status ) { /* translators: %s: booking status, e.g. "Canceled". */ $events[] = self::event( self::EVENT_STATUS . $status, sprintf( __( 'FluentBooking: booking status changed to "%s"', '85pl-android-sms-api' ), $statuses[ $status ] ), $status ); } $events[] = self::event( self::EVENT_RESCHEDULED, __( 'FluentBooking: booking rescheduled', '85pl-android-sms-api' ), '' ); $events[] = self::event( self::EVENT_REMINDER, __( 'FluentBooking: reminder before the meeting', '85pl-android-sms-api' ), '' ); return $events; } /** * Hooks fluent_booking/after_booking_scheduled / _pending: a new booking (one row per guest). * * @param mixed $booking FluentBooking's booking. * @return void */ public static function on_booking_created( $booking ): void { $id = self::id_of( $booking ); if ( $id < 1 || isset( self::$confirmed[ $id ] ) ) { return; } try { $context = self::context( $id ); if ( null !== $context && in_array( $context['status_code'], self::NEW_STATUSES, true ) ) { Events::fire( self::EVENT_CREATED, $context ); } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_CREATED ); } self::plan_reminders( $id ); } /** * Hooks fluent_booking/booking_schedule_: fire the event of the booking's current status. The * status is the dedupe suffix: the same status reported twice sends one SMS. * * @param mixed $booking FluentBooking's booking. * @return void */ public static function on_status( $booking ): void { $id = self::id_of( $booking ); if ( $id < 1 ) { return; } try { $context = self::context( $id ); if ( null === $context || ! in_array( $context['status_code'], self::STATUSES, true ) ) { return; } if ( 'scheduled' === $context['status_code'] ) { self::$confirmed[ $id ] = true; } Events::fire( self::EVENT_STATUS . $context['status_code'], $context, $context['status_code'] ); } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_STATUS ); } } /** * Hook fluent_booking/after_booking_rescheduled: one SMS per new time, reminders for it. * * @param mixed $booking FluentBooking's booking, already at its new time. * @return void */ public static function on_rescheduled( $booking ): void { $id = self::id_of( $booking ); if ( $id < 1 ) { return; } try { $context = self::context( $id ); if ( null !== $context ) { Events::fire( self::EVENT_RESCHEDULED, $context, (string) $context['start'] ); } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_RESCHEDULED ); } self::plan_reminders( $id ); } /** * Job: reminder of one rule for one booking. Skipped when the booking is gone, cancelled 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 Meeting 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; } // A full calendar reminds together: no per-rule hourly cap (country and per-number limits 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. 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 ); } } /** * Plan a reminder job per enabled reminder rule (hours_before before the start; none when that time has * passed or the same job already waits, e.g. a pending booking the host has just confirmed). * * @param int $id Booking ID. * @return void */ private static function plan_reminders( int $id ): void { try { $rules = Rules::enabled_for_event( self::EVENT_REMINDER ); $context = array() === $rules ? null : self::context( $id ); if ( null === $context || ! in_array( $context['status_code'], self::REMINDER_STATUSES, true ) ) { return; } foreach ( $rules as $rule ) { $when = (int) $context['start'] - (int) $rule['hours_before'] * HOUR_IN_SECONDS; $args = array( $id, (int) $rule['id'], (int) $context['start'] ); if ( $when > time() && ! Queue::is_hook_scheduled( self::REMINDER_HOOK, $args ) ) { Queue::schedule_hook( self::REMINDER_HOOK, $args, $when ); } } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_REMINDER ); } } /** * Bookings (scheduled 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 -- FluentBooking's table, prefix from $wpdb; the sweep needs the current state. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, start_time, created_at FROM {$wpdb->prefix}fcal_bookings WHERE booking_type = 'scheduling' AND status IN ( 'scheduled', 'pending' ) AND start_time > %s AND start_time <= %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_at'] ) ? false : strtotime( $row['created_at'] . ' UTC' ); $result[] = array( 'id' => (int) $row['id'], 'start' => (int) strtotime( $row['start_time'] . ' UTC' ), 'created' => false === $created ? null : $created, ); } return $result; } /** * Numbers of a person for the privacy tools: the phones of their FluentBooking bookings. * * @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 -- FluentBooking's table, a rare privacy request. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}fcal_bookings WHERE email = %s", (string) $email ) ); $wpdb->suppress_errors( $suppress ); foreach ( is_array( $ids ) ? $ids : array() as $id ) { $booking = self::booking( (int) $id ); $phone = null === $booking ? null : self::phone( $booking ); if ( null !== $phone ) { $phones[] = $phone; } } return array_values( array_unique( array_filter( $phones, 'is_string' ) ) ); } /** * Template variables and the guest's number of a booking, from FluentBooking's model. * * @param int $booking_id Booking ID. * @return array|null Null when there is no such booking. */ public static function context( int $booking_id ): ?array { $booking = self::booking( $booking_id ); if ( null === $booking || null === $booking->calendar_event ) { return null; } $start = (int) strtotime( $booking->start_time . ' UTC' ); $status = (string) $booking->status; $statuses = self::statuses(); $host = $booking->getHostDetails( false ); $details = (array) $booking->location_details; $location = trim( (string) $booking->getLocationAsText() ); return array( 'booking_id' => (int) $booking->id, 'group_id' => (int) $booking->group_id, 'imie' => (string) $booking->first_name, 'nazwisko' => (string) $booking->last_name, 'usluga' => (string) $booking->calendar_event->title, 'pracownik' => is_array( $host ) ? (string) ( $host['name'] ?? '' ) : '', 'data' => wp_date( (string) get_option( 'date_format' ), $start ), 'godzina' => wp_date( (string) get_option( 'time_format' ), $start ), 'czas_trwania' => (string) (int) $booking->slot_minutes, 'miejsce' => '--' === $location ? '' : $location, 'link_spotkania' => (string) ( $details['online_platform_link'] ?? '' ), 'link_odwolania' => (string) $booking->getCancelUrl(), 'link_zmiany_terminu' => (string) $booking->getRescheduleUrl(), 'status' => $statuses[ $status ] ?? $status, 'numer_rezerwacji' => (string) $booking->id, 'nazwa_firmy' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), 'telefon' => self::phone( $booking ), 'status_code' => $status, 'start' => $start, ); } /** * The guest's number: the booking form's phone field marked as the SMS number, else the booking's phone * (the "phone call" location asks for it). FluentBooking collects no phone otherwise. * * @param Model $booking Booking. * @return string|null */ private static function phone( $booking ): ?string { $phone = null === $booking->calendar_event ? (string) $booking->phone : (string) $booking->getInviteePhoneNumber( $booking->calendar_event ); return '' === trim( $phone ) ? null : Phone::normalize( $phone, Phone::default_country_code() ); } /** * A booking with its event type and calendar; null when there is none or FluentBooking's tables fail. * * @param int $booking_id Booking ID. * @return Model|null */ private static function booking( int $booking_id ) { global $wpdb; if ( $booking_id < 1 ) { return null; } // FluentBooking's schema is not ours: a missing table or column must not print errors on the page. $suppress = $wpdb->suppress_errors( true ); try { $booking = Model::with( array( 'calendar_event', 'calendar' ) )->find( $booking_id ); } catch ( \Throwable $e ) { $booking = null; } finally { $wpdb->suppress_errors( $suppress ); } return $booking instanceof Model ? $booking : null; } /** * 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/fluent-booking.png', 'variables' => Variables::for_editor( array( 'imie' => __( 'First name', '85pl-android-sms-api' ), 'nazwisko' => __( 'Last name', '85pl-android-sms-api' ), 'usluga' => __( 'Meeting type', '85pl-android-sms-api' ), 'pracownik' => __( 'Host', '85pl-android-sms-api' ), 'data' => __( 'Date', '85pl-android-sms-api' ), 'godzina' => __( 'Time', '85pl-android-sms-api' ), 'czas_trwania' => __( 'Duration (minutes)', '85pl-android-sms-api' ), 'miejsce' => __( 'Location', '85pl-android-sms-api' ), 'link_spotkania' => __( 'Online meeting link', '85pl-android-sms-api' ), 'link_odwolania' => __( 'Cancellation link', '85pl-android-sms-api' ), 'link_zmiany_terminu' => __( 'Reschedule link', '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' => self::reservation_key( $context ), 'url' => admin_url( 'admin.php?page=fluent-booking#/scheduled-events?booking_id=' . rawurlencode( (string) ( $context['booking_id'] ?? '' ) ) ), ); }, 'sample' => static function () use ( $status ) { return self::sample( $status ); }, 'rule_fields' => self::EVENT_REMINDER === $id ? array( 'hours_before' ) : array(), ); } /** * The SMS's object: one reservation, i.e. the booking rows of one slot (FluentBooking's group_id) that share * a phone. Guests added by a booker carry the booker's phone, so the booker's number gets one SMS per event, * not one per guest; a guest with a phone of their own is a reservation of their own. A row without a phone * stands alone. The number itself is hashed: the ID is stored and shown, the phone must not be. * * @param array $context Booking context. * @return string */ private static function reservation_key( array $context ): string { $group = (string) ( $context['group_id'] ?? '' ); $id = (string) ( $context['booking_id'] ?? '' ); $phone = isset( $context['telefon'] ) && is_string( $context['telefon'] ) ? $context['telefon'] : ''; if ( '' === $group || '0' === $group ) { return $id; } return $group . '-' . ( '' === $phone ? 'b' . $id : substr( hash( 'sha256', $phone ), 0, 16 ) ); } /** * Context of the latest 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 -- FluentBooking's table, prefix from $wpdb. $id = '' === $status ? $wpdb->get_var( "SELECT MAX(id) FROM {$wpdb->prefix}fcal_bookings WHERE booking_type = 'scheduling'" ) : $wpdb->get_var( $wpdb->prepare( "SELECT MAX(id) FROM {$wpdb->prefix}fcal_bookings WHERE booking_type = 'scheduling' AND status = %s", $status ) ); // phpcs:enable $wpdb->suppress_errors( $suppress ); return null === $id ? null : self::context( (int) $id ); } /** * Status names. * * @return array */ private static function statuses(): array { return array( 'scheduled' => __( 'Scheduled', '85pl-android-sms-api' ), 'pending' => __( 'Pending', '85pl-android-sms-api' ), 'completed' => __( 'Completed', '85pl-android-sms-api' ), 'cancelled' => __( 'Canceled', '85pl-android-sms-api' ), 'rejected' => __( 'Rejected', '85pl-android-sms-api' ), 'no_show' => __( 'No-show', '85pl-android-sms-api' ), ); } /** * ID of a booking payload (FluentBooking's model). * * @param mixed $booking Booking. * @return int */ private static function id_of( $booking ): int { return is_object( $booking ) && isset( $booking->id ) && is_scalar( $booking->id ) ? absint( $booking->id ) : 0; } }