ics_sequence ?? 0 ) : 0; } wp_schedule_single_event( time(), self::HOOK_BOOKING_RESCHEDULED, [ $booking_id, $old_start, $old_staff_id, $sequence ] ); } /** * After a move: drop the queued reminders of the old time (the 24-hour one * here, add-ons theirs on `vizyt_booking_reminders_cleared`) and plan * them for the new time. */ public static function reschedule_booking_reminder( int $booking_id, string $start_datetime ): void { wp_clear_scheduled_hook( self::HOOK_BOOKING_REMINDER, [ $booking_id ] ); /** * The booking's queued reminders were dropped (it moved); add-ons drop theirs. * * @param int $booking_id */ do_action( 'vizyt_booking_reminders_cleared', $booking_id ); self::schedule_booking_reminder( $booking_id, $start_datetime ); } /** * Schedule a 24-hour reminder. Called right after booking is created. * Fires at start_datetime - 24 hours. Skipped if the booking is less than 24h away. */ public static function schedule_booking_reminder( int $booking_id, string $start_datetime ): void { try { // Wall-clock time in the business timezone → real timestamp. $start = new \DateTimeImmutable( $start_datetime, vizyt_timezone() ); $fire_at = $start->modify( '-24 hours' )->getTimestamp(); } catch ( \Exception $e ) { return; } // Less than 24h away: no 24-hour reminder. The action still fires, so // add-ons can plan their shorter reminders (2 h, 1 h …). if ( $fire_at > time() ) { // false = refused (an identical reminder is already queued, or a // pre_schedule_event filter said no): no history row for it. if ( true === wp_schedule_single_event( $fire_at, self::HOOK_BOOKING_REMINDER, [ $booking_id ] ) ) { BookingEvent::add( $booking_id, 'reminder_scheduled', [ 'at' => gmdate( 'Y-m-d H:i:s', $fire_at ) ], 0 ); } } do_action( 'vizyt_booking_reminder_scheduled', $booking_id, $start_datetime ); } /** * WP Cron callback: send booking-created emails (customer + admin). */ public static function send_booking_created( int $booking_id ): void { $data = self::build_email_data( $booking_id ); if ( ! $data ) { return; } $site_name = Settings::get_business_name(); $date_label = date_i18n( get_option( 'date_format' ), strtotime( $data['start_datetime'] ) ); $time_label = date_i18n( get_option( 'time_format' ), strtotime( $data['start_datetime'] ) ); // Email to customer $customer_subject = sprintf( /* translators: %s: service name */ __( 'Booking confirmed — %s', 'vizyt' ), $data['service_name'] ); $customer_message = sprintf( /* translators: 1: customer name 2: service name 3: date 4: time 5: staff name 6: site name 7: link to the booking page */ __( "Hi %1\$s,\n\nYour booking has been successfully created.\n\nService: %2\$s\nDate: %3\$s\nTime: %4\$s\nStaff: %5\$s\n\nThank you for choosing %6\$s!\n\nChange or cancel your booking:\n%7\$s", 'vizyt' ), $data['customer_name'], $data['service_name'], $date_label, $time_label, $data['staff_name'], $site_name, (string) ( $data['manage_url'] ?? '' ) ); /** * Fires before a booking-created notification email is sent. * * @param string $type 'customer' or 'admin' * @param string $to Recipient email address. * @param string $subject Email subject. * @param string $message Email body. * @param int $booking_id */ $customer_subject = apply_filters( 'vizyt_notification_subject', $customer_subject, 'booking_created_customer', $booking_id, $data ); $customer_message = apply_filters( 'vizyt_notification_message', $customer_message, 'booking_created_customer', $booking_id, $data ); $customer_headers = apply_filters( 'vizyt_notification_headers', [], 'booking_created_customer', $booking_id ); do_action( 'vizyt_notification_send', 'customer', $data['customer_email'], $customer_subject, $customer_message, $booking_id ); $sent = self::mail( $data['customer_email'], $customer_subject, $customer_message, $customer_headers, 'booking_created_customer', $booking_id ); self::log_email( $booking_id, 'customer', 'created', (bool) $sent ); // Email to admin $admin_email = Settings::get_notification_email(); $admin_subject = sprintf( /* translators: %s: service name */ __( 'New booking — %s', 'vizyt' ), $data['service_name'] ); $admin_message = sprintf( /* translators: 1: customer name 2: service name 3: date 4: time 5: staff name */ __( "New booking!\n\nCustomer: %1\$s\nService: %2\$s\nDate: %3\$s\nTime: %4\$s\nStaff: %5\$s", 'vizyt' ), $data['customer_name'], $data['service_name'], $date_label, $time_label, $data['staff_name'] ); $admin_subject = apply_filters( 'vizyt_notification_subject', $admin_subject, 'booking_created_admin', $booking_id, $data ); $admin_message = apply_filters( 'vizyt_notification_message', $admin_message, 'booking_created_admin', $booking_id, $data ); $admin_headers = apply_filters( 'vizyt_notification_headers', [], 'booking_created_admin', $booking_id ); do_action( 'vizyt_notification_send', 'admin', $admin_email, $admin_subject, $admin_message, $booking_id ); $sent = self::mail( $admin_email, $admin_subject, $admin_message, $admin_headers, 'booking_created_admin', $booking_id ); self::log_email( $booking_id, 'admin', 'created', (bool) $sent ); } /** * WP Cron callback: send booking-cancelled email to customer. */ public static function send_booking_cancelled( int $booking_id ): void { $data = self::build_email_data( $booking_id ); if ( ! $data ) { return; } $site_name = Settings::get_business_name(); $date_label = date_i18n( get_option( 'date_format' ), strtotime( $data['start_datetime'] ) ); $time_label = date_i18n( get_option( 'time_format' ), strtotime( $data['start_datetime'] ) ); $subject = sprintf( /* translators: %s: service name */ __( 'Booking cancelled — %s', 'vizyt' ), $data['service_name'] ); $cancel_reason = trim( $data['cancel_reason'] ?? '' ); $reason_line = $cancel_reason !== '' /* translators: %s: the reason the booking was cancelled */ ? sprintf( "\n" . __( 'Reason: %s', 'vizyt' ), $cancel_reason ) : ''; $message = sprintf( /* translators: 1: customer name 2: service name 3: date 4: time 5: cancel reason line 6: site name */ __( "Hi %1\$s,\n\nYour booking for %2\$s on %3\$s at %4\$s has been cancelled.%5\$s\n\nIf you have any questions, please contact %6\$s.", 'vizyt' ), $data['customer_name'], $data['service_name'], $date_label, $time_label, $reason_line, $site_name ); $subject = apply_filters( 'vizyt_notification_subject', $subject, 'booking_cancelled', $booking_id, $data ); $message = apply_filters( 'vizyt_notification_message', $message, 'booking_cancelled', $booking_id, $data ); $headers = apply_filters( 'vizyt_notification_headers', [], 'booking_cancelled', $booking_id ); /** This action is documented in includes/Notifications/NotificationService.php */ do_action( 'vizyt_notification_send', 'customer', $data['customer_email'], $subject, $message, $booking_id ); $sent = self::mail( $data['customer_email'], $subject, $message, $headers, 'booking_cancelled', $booking_id ); self::log_email( $booking_id, 'customer', 'cancelled', (bool) $sent ); } /** * WP Cron callback: "booking moved" emails — to the customer (new time, * the booking link, booking.ics) and to the admin (was → now). Nothing * is sent when the booking is no longer pending or confirmed. Without the * old time (an event queued without it) the "Was" line is left out. * * An add-on from before 1.6 (Vizyt Pro < 1.5) rewrites every Vizyt email * it is offered on the subject/message filters and knows nothing of these * two types (its body would lose the move), so they go out as core text. * * @param string $old_start previous start, "Y-m-d H:i:s" business time * @param int $old_staff_id previous specialist * @param int $sequence the booking's ics_sequence after the move; only makes every queued event * unique (WP Cron drops an identical hook + args within 10 minutes), unused here */ public static function send_booking_rescheduled( int $booking_id, string $old_start = '', int $old_staff_id = 0, int $sequence = 0 ): void { $booking = Booking::getById( $booking_id ); if ( ! $booking || ! in_array( (string) $booking->status, Booking::ACTIVE, true ) ) { return; } $data = self::build_email_data( $booking_id ); if ( ! $data ) { return; } $old_staff = $old_staff_id ? Staff::getById( $old_staff_id ) : null; $lang = \Vizyt\Integrations\Multilingual::booking_lang( $booking_id ); $data['old_start_datetime'] = $old_start; $data['old_staff_name'] = $old_staff ? vizyt_translate( (string) $old_staff->name, 'staff_' . $old_staff_id . '_name', '' !== $lang ? $lang : null ) : ''; $date_format = (string) get_option( 'date_format' ); $time_format = (string) get_option( 'time_format' ); $new_ts = strtotime( $data['start_datetime'] ); $old_ts = '' !== $old_start ? strtotime( $old_start ) : false; $was = $old_ts ? date_i18n( $date_format, $old_ts ) . ', ' . date_i18n( $time_format, $old_ts ) : ''; $now = date_i18n( $date_format, $new_ts ) . ', ' . date_i18n( $time_format, $new_ts ); /** * Whether the installed add-on would override the "booking moved" * emails badly: then they skip vizyt_notification_subject/_message/_headers. * * @param bool $raw true with Vizyt Pro older than 1.5.0 */ $raw = (bool) apply_filters( 'vizyt_pro_overrides_rescheduled_emails', defined( 'VIZYT_PRO_VERSION' ) && version_compare( (string) VIZYT_PRO_VERSION, '1.5.0', '<' ) ); // Customer $subject = sprintf( /* translators: %s: service name */ __( 'Your booking was moved — %s', 'vizyt' ), $data['service_name'] ); $message = '' !== $was ? sprintf( /* translators: 1: customer name 2: service name 3: new date 4: new time 5: staff name 6: previous date and time 7: site name 8: link to the booking page */ __( "Hi %1\$s,\n\nYour booking has been moved to a new time.\n\nService: %2\$s\nDate: %3\$s\nTime: %4\$s\nStaff: %5\$s\nWas: %6\$s\n\nSee you soon!\n%7\$s\n\nChange or cancel your booking: %8\$s", 'vizyt' ), $data['customer_name'], $data['service_name'], date_i18n( $date_format, $new_ts ), date_i18n( $time_format, $new_ts ), $data['staff_name'], $was, Settings::get_business_name(), (string) ( $data['manage_url'] ?? '' ) ) : sprintf( /* translators: 1: customer name 2: service name 3: new date 4: new time 5: staff name 6: site name 7: link to the booking page */ __( "Hi %1\$s,\n\nYour booking has been moved to a new time.\n\nService: %2\$s\nDate: %3\$s\nTime: %4\$s\nStaff: %5\$s\n\nSee you soon!\n%6\$s\n\nChange or cancel your booking: %7\$s", 'vizyt' ), $data['customer_name'], $data['service_name'], date_i18n( $date_format, $new_ts ), date_i18n( $time_format, $new_ts ), $data['staff_name'], Settings::get_business_name(), (string) ( $data['manage_url'] ?? '' ) ); $headers = []; if ( ! $raw ) { $subject = apply_filters( 'vizyt_notification_subject', $subject, 'booking_rescheduled_customer', $booking_id, $data ); $message = apply_filters( 'vizyt_notification_message', $message, 'booking_rescheduled_customer', $booking_id, $data ); $headers = apply_filters( 'vizyt_notification_headers', [], 'booking_rescheduled_customer', $booking_id ); } /** This action is documented in includes/Notifications/NotificationService.php */ do_action( 'vizyt_notification_send', 'customer', $data['customer_email'], $subject, $message, $booking_id ); $sent = self::mail( $data['customer_email'], $subject, $message, $headers, 'booking_rescheduled_customer', $booking_id ); self::log_email( $booking_id, 'customer', 'rescheduled', (bool) $sent ); // Admin $staff_line = '' !== $data['old_staff_name'] && $data['old_staff_name'] !== $data['staff_name'] ? $data['old_staff_name'] . ' → ' . $data['staff_name'] : $data['staff_name']; $admin_email = Settings::get_notification_email(); if ( '' !== $was ) { $admin_subject = sprintf( /* translators: 1: previous date and time, 2: new date and time */ __( 'Booking moved: %1$s → %2$s', 'vizyt' ), $was, $now ); $admin_message = sprintf( /* translators: 1: customer name 2: service name 3: staff name (or "previous → new") 4: previous date and time 5: new date and time */ __( "A customer moved their booking.\n\nCustomer: %1\$s\nService: %2\$s\nStaff: %3\$s\nWas: %4\$s\nNow: %5\$s", 'vizyt' ), $data['customer_name'], $data['service_name'], $staff_line, $was, $now ); } else { $admin_subject = sprintf( /* translators: %s: new date and time */ __( 'Booking moved: %s', 'vizyt' ), $now ); $admin_message = sprintf( /* translators: 1: customer name 2: service name 3: staff name (or "previous → new") 4: new date and time */ __( "A customer moved their booking.\n\nCustomer: %1\$s\nService: %2\$s\nStaff: %3\$s\nNow: %4\$s", 'vizyt' ), $data['customer_name'], $data['service_name'], $staff_line, $now ); } $admin_headers = []; if ( ! $raw ) { $admin_subject = apply_filters( 'vizyt_notification_subject', $admin_subject, 'booking_rescheduled_admin', $booking_id, $data ); $admin_message = apply_filters( 'vizyt_notification_message', $admin_message, 'booking_rescheduled_admin', $booking_id, $data ); $admin_headers = apply_filters( 'vizyt_notification_headers', [], 'booking_rescheduled_admin', $booking_id ); } do_action( 'vizyt_notification_send', 'admin', $admin_email, $admin_subject, $admin_message, $booking_id ); $sent = self::mail( $admin_email, $admin_subject, $admin_message, $admin_headers, 'booking_rescheduled_admin', $booking_id ); self::log_email( $booking_id, 'admin', 'rescheduled', (bool) $sent ); } /** Emails that carry the booking as a calendar file (booking.ics). */ const ICS_TYPES = [ 'booking_created_customer', 'booking_rescheduled_customer' ]; /** * wp_mail() with the type's attachments: booking.ics for ICS_TYPES (a * temporary file, removed once wp_mail() returns, also when sending * fails) plus whatever `vizyt_notification_attachments` adds. * * @param string|string[] $headers */ private static function mail( string $to, string $subject, string $message, $headers, string $type, int $booking_id ): bool { $temp = in_array( $type, self::ICS_TYPES, true ) ? self::ics_file( $booking_id ) : []; $files = isset( $temp['path'] ) ? [ 'booking.ics' => $temp['path'] ] : []; /** * Files attached to a Vizyt email: absolute paths; a string key is the * name the recipient sees. The customer's confirmation and "booking * moved" email carry booking.ics. * * Vizyt's own booking.ics is a temporary file deleted as soon as * wp_mail() returns. A mail plugin that queues messages and sends them * later will find it gone: return [] here for these types, or attach a * file of your own that stays where it is. * * @param array $files * @param string $type booking_created_customer, booking_created_admin, booking_cancelled, * booking_reminder, booking_rescheduled_customer, booking_rescheduled_admin * @param int $booking_id */ $files = apply_filters( 'vizyt_notification_attachments', $files, $type, $booking_id ); $files = array_filter( is_array( $files ) ? $files : [], static fn( $file ) => is_string( $file ) && '' !== $file ); try { return (bool) wp_mail( $to, $subject, $message, $headers, $files ); } finally { self::remove_temp( $temp ); } } /** * The booking as booking.ics in a new temporary folder (the file name is * what mail clients show). [] when it cannot be written. * * @return array{dir?: string, path?: string} */ private static function ics_file( int $booking_id ): array { $booking = Booking::getById( $booking_id ); if ( ! $booking ) { return []; } // get_temp_dir() may fall back to WP_CONTENT_DIR: the folder name is random and the file holds no booking token. $dir = trailingslashit( get_temp_dir() ) . 'vizyt-' . strtolower( wp_generate_password( 16, false ) ); if ( ! wp_mkdir_p( $dir ) ) { return []; } $path = $dir . '/booking.ics'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temporary email attachment, removed right after wp_mail(); WP_Filesystem may ask for FTP credentials in a cron request. if ( false === file_put_contents( $path, Ics::forBooking( $booking ) ) ) { self::remove_temp( [ 'dir' => $dir ] ); return []; } return [ 'dir' => $dir, 'path' => $path ]; } /** * @param array{dir?: string, path?: string} $temp from ics_file() */ private static function remove_temp( array $temp ): void { if ( ! empty( $temp['path'] ) ) { wp_delete_file( $temp['path'] ); } // Only when the file is gone: a `wp_delete_file` filter may have kept it. if ( ! empty( $temp['dir'] ) && is_dir( $temp['dir'] ) && ( empty( $temp['path'] ) || ! file_exists( $temp['path'] ) ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- the empty temporary folder made by ics_file(). rmdir( $temp['dir'] ); } } /** * Booking history (3e): which email went out (its kind — never the * rendered subject, which carries names), to whom (role, not address) * and whether wp_mail() accepted it. */ private static function log_email( int $booking_id, string $to, string $kind, bool $sent ): void { BookingEvent::add( $booking_id, 'email', [ 'to' => $to, 'kind' => $kind, 'sent' => $sent ], 0 ); } /** * Load all data needed for notification emails. * * @return array{customer_name:string, customer_email:string, service_name:string, staff_name:string, start_datetime:string, cancel_reason:string, cancel_url:string, manage_url:string}|null */ public static function build_email_data( int $booking_id ): ?array { $booking = Booking::getById( $booking_id ); if ( ! $booking ) { return null; } $customer = Customer::getById( (int) $booking->customer_id ); if ( ! $customer ) { return null; } $service = Service::getById( (int) $booking->service_id ); $staff = Staff::getById( (int) $booking->staff_id ); $data = [ 'service_id' => (int) $booking->service_id, 'staff_id' => (int) $booking->staff_id, 'customer_name' => $customer->first_name, 'customer_email' => $customer->email, 'service_name' => $service ? $service->name : __( '(service deleted)', 'vizyt' ), 'staff_name' => $staff ? $staff->name : __( '(staff deleted)', 'vizyt' ), 'start_datetime' => $booking->start_datetime, 'cancel_reason' => $booking->cancel_reason ?? '', 'cancel_url' => vizyt_manage_url( (string) $booking->token, 'cancel' ), 'manage_url' => vizyt_manage_url( (string) $booking->token ), ]; /** * Email data for a booking (multilingual plugins translate names here). * * @param array $data * @param int $booking_id */ return (array) apply_filters( 'vizyt_email_data', $data, $booking_id ); } /** * WP Cron callback: send 24-hour reminder to customer. */ public static function send_booking_reminder( int $booking_id ): void { $data = self::build_email_data( $booking_id ); if ( ! $data ) { return; } // Don't send if already cancelled or completed $booking = Booking::getById( $booking_id ); if ( ! $booking || ! in_array( $booking->status, [ 'pending', 'confirmed' ], true ) ) { return; } $site_name = Settings::get_business_name(); $date_label = date_i18n( get_option( 'date_format' ), strtotime( $data['start_datetime'] ) ); $time_label = date_i18n( get_option( 'time_format' ), strtotime( $data['start_datetime'] ) ); $subject = sprintf( /* translators: %s: service name */ __( 'Reminder: your booking tomorrow — %s', 'vizyt' ), $data['service_name'] ); $message = sprintf( /* translators: 1: customer name 2: service name 3: date 4: time 5: staff name 6: site name 7: link to the booking page */ __( "Hi %1\$s,\n\nThis is a reminder for your upcoming booking:\n\nService: %2\$s\nDate: %3\$s\nTime: %4\$s\nStaff: %5\$s\n\nSee you soon!\n%6\$s\n\nChange or cancel your booking: %7\$s", 'vizyt' ), $data['customer_name'], $data['service_name'], $date_label, $time_label, $data['staff_name'], $site_name, (string) ( $data['manage_url'] ?? '' ) ); $subject = apply_filters( 'vizyt_notification_subject', $subject, 'booking_reminder', $booking_id, $data ); $message = apply_filters( 'vizyt_notification_message', $message, 'booking_reminder', $booking_id, $data ); $headers = apply_filters( 'vizyt_notification_headers', [], 'booking_reminder', $booking_id ); do_action( 'vizyt_notification_send', 'reminder', $data['customer_email'], $subject, $message, $booking_id ); $sent = self::mail( $data['customer_email'], $subject, $message, $headers, 'booking_reminder', $booking_id ); BookingEvent::add( $booking_id, 'reminder_sent', [ 'kind' => 'reminder', 'sent' => (bool) $sent ], 0 ); } }