$template_keys Template keys. * * @return list * * @since 0.1.0 */ public function dispatch_for_booking( int $booking_id, array $template_keys ): array { $booking = $this->bookings->find_by_id( $booking_id ); if ( null === $booking ) { return []; } $created = []; $now = $this->clock->now(); foreach ( $template_keys as $key ) { $entry = $this->queue_template( $booking, $key, null, $now ); if ( null !== $entry ) { $created[] = $entry; } } return $created; } /** * Queue a template from an automation send step (draft-first). * * @param AutomationContext $ctx Run context. * @param ActionStep $step Send step. * * @since 0.1.0 */ public function queue_for_automation( AutomationContext $ctx, ActionStep $step ): ?NotificationLogEntry { $template_key = isset( $step->config()['template_key'] ) ? (string) $step->config()['template_key'] : ''; if ( '' === $template_key ) { return null; } $template = $this->templates->find_by_key( $template_key, 'email', 'en_GB' ); if ( null === $template || ! $template->is_enabled() ) { return null; } $channel = $this->channels->get( $template->channel() ); if ( null === $channel || ! $channel->supports( $template ) ) { return null; } $booking_id = null; $customer_id = $ctx->customer_id(); $var_context = null; if ( SubjectType::Booking === $ctx->subject_type() ) { $booking = $this->bookings->find_by_id( $ctx->subject_id() ); if ( null === $booking ) { return null; } $booking_id = (int) $booking->id(); $customer_id = $booking->customer_id(); $var_context = $this->build_context( $booking ); } elseif ( SubjectType::Customer === $ctx->subject_type() ) { $customer = $this->customers->find_by_id( $ctx->subject_id() ); if ( null === $customer ) { return null; } $customer_id = (int) $customer->id(); $var_context = $this->build_context_for_customer( $customer ); } else { return null; } $recipient_role = $step->recipient() ?? AutomationRecipient::Customer; $recipient = $this->resolve_automation_recipient( $recipient_role, $booking_id, $customer_id, $ctx ); if ( null === $recipient ) { return null; } $idempotency = sprintf( 'automation:%s:%d:%s', $ctx->trigger_event_id(), $step->sort_order(), $template_key ); $existing = $this->logs->find_by_idempotency_key( $idempotency ); if ( null !== $existing ) { return $existing; } $bag = $this->filter_variable_bag( $this->variables->resolve( $var_context ), $var_context ); $rendered = $channel->render( $template, $bag ); $now = $this->clock->now(); $status = OutboundGuard::allows_outbound() ? NotificationStatus::Queued : NotificationStatus::Drafted; $entry = new NotificationLogEntry( null, Uuid::generate(), $booking_id, $customer_id, $template_key, $template->channel(), $recipient->address(), $rendered->subject(), $rendered->body_html(), $status, $now, null, 0, null, null, $idempotency, null, $now, $now, ); $this->logs->save( $entry ); if ( NotificationStatus::Drafted === $status ) { return $entry; } $action_id = $this->scheduler->schedule_single( self::SEND_HOOK, [ 'log_id' => (int) $entry->id() ], $now, 'bookline', 'notify:' . $idempotency ); if ( $action_id > 0 ) { $entry->bind_action( $action_id, $now ); $this->logs->save( $entry ); } return $entry; } /** * Queue one template for a booking. * * @param Booking $booking Booking. * @param string $template_key Template key. * @param int|null $reminder_id Reminder rule id. * @param DateTimeImmutable $scheduled_for Send time. * @param string $deliver_channel email, sms or whatsapp. * * @since 0.1.0 */ public function queue_template( Booking $booking, string $template_key, ?int $reminder_id, DateTimeImmutable $scheduled_for, string $deliver_channel = 'email', ): ?NotificationLogEntry { $deliver_channel = in_array( $deliver_channel, [ 'email', 'sms', 'whatsapp' ], true ) ? $deliver_channel : 'email'; $template = $this->templates->find_by_key( $template_key, $deliver_channel, 'en_GB' ); if ( null === $template ) { $template = $this->templates->find_by_key( $template_key, 'email', 'en_GB' ); } if ( null === $template || ! $template->is_enabled() ) { return null; } $idempotency = sprintf( '%s:%s:%d:%s:%s', $template_key, $deliver_channel, (int) $booking->id(), null === $reminder_id ? '0' : (string) $reminder_id, $scheduled_for->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' ) ); $existing = $this->logs->find_by_idempotency_key( $idempotency ); if ( null !== $existing ) { return $existing; } $channel = $this->channels->get( $deliver_channel ); if ( null === $channel || ! $channel->supports( $template ) ) { return null; } $context = $this->build_context( $booking ); $bag = $this->filter_variable_bag( $this->variables->resolve( $context ), $context ); $rendered = $channel->render( $template, $bag ); $recipient = $this->resolve_recipient( $booking, $template->recipient(), $template->custom_recipient(), $deliver_channel ); if ( null === $recipient ) { return null; } $sms_like = in_array( $deliver_channel, [ 'sms', 'whatsapp' ], true ); $body = $sms_like ? $rendered->body_text() : $rendered->body_html(); $ics = null; if ( ! $sms_like && ( str_contains( $template_key, 'confirmed' ) || str_contains( $template_key, 'rescheduled' ) ) ) { $ics = $this->ics->generate( $booking->uuid()->to_string() . '@bookline', $context->get( 'service.name', 'Booking' ), $context->get( 'service.name', 'Booking' ), $booking->start_utc(), $booking->end_utc(), '' !== $context->get( 'location.address' ) ? $context->get( 'location.address' ) : null ); $body .= "\n"; $rendered = new RenderedMessage( $rendered->subject(), $body, $rendered->body_text(), $ics ); } $now = $this->clock->now(); $status = OutboundGuard::allows_outbound() ? NotificationStatus::Queued : NotificationStatus::Drafted; $entry = new NotificationLogEntry( null, Uuid::generate(), (int) $booking->id(), $booking->customer_id(), $template_key, $deliver_channel, $recipient->address(), $rendered->subject(), $body, $status, $scheduled_for, null, 0, null, null, $idempotency, $reminder_id, $now, $now, ); $this->logs->save( $entry ); if ( NotificationStatus::Drafted === $status ) { return $entry; } $action_id = $this->scheduler->schedule_single( self::SEND_HOOK, [ 'log_id' => (int) $entry->id() ], $scheduled_for, 'bookline', 'notify:' . $idempotency ); if ( $action_id > 0 ) { $entry->bind_action( $action_id, $now ); $this->logs->save( $entry ); } return $entry; } /** * Process a queued log row (Action Scheduler callback). * * @param int $log_id Log id. * * @since 0.1.0 */ public function process_log( int $log_id ): void { $entry = $this->logs->find_by_id( $log_id ); if ( null === $entry ) { return; } if ( in_array( $entry->status(), [ NotificationStatus::Sent, NotificationStatus::Cancelled, NotificationStatus::Drafted ], true ) ) { return; } if ( ! OutboundGuard::allows_outbound() ) { $entry->mark_drafted( $this->clock->now() ); $this->logs->save( $entry ); return; } $channel = $this->channels->get( $entry->channel() ); if ( null === $channel ) { $entry->mark_failed( 'Channel missing.', $this->clock->now() ); $this->logs->save( $entry ); return; } $now = $this->clock->now(); $entry->mark_sending( $now ); $this->logs->save( $entry ); $body_html = (string) $entry->body(); $body_text = function_exists( 'wp_strip_all_tags' ) ? wp_strip_all_tags( $body_html ) : (string) ( preg_replace( '/<[^>]+>/', '', $body_html ) ?? $body_html ); $message = new RenderedMessage( (string) $entry->subject(), $body_html, $body_text, null ); $result = $channel->send( $message, new Recipient( $entry->recipient() ) ); if ( $result->success() ) { $entry->mark_sent( $this->clock->now() ); $this->logs->save( $entry ); return; } if ( $result->retryable() && $this->retries->can_retry( $entry->attempts() ) ) { $entry->requeue( $this->clock->now() ); $this->logs->save( $entry ); $next = $this->retries->next_attempt_at( $this->clock->now(), $entry->attempts() ); $this->scheduler->schedule_single( self::SEND_HOOK, [ 'log_id' => (int) $entry->id() ], $next, 'bookline', 'notify-retry:' . (string) $entry->id() . ':' . (string) $entry->attempts() ); return; } $entry->mark_failed( (string) ( $result->error() ?? 'Send failed.' ), $this->clock->now() ); $this->logs->save( $entry ); } /** * Build variable context for a booking. * * @param Booking $booking Booking. * * @since 0.1.0 */ public function build_context( Booking $booking ): VariableContext { $customer = $this->customers->find_by_id( $booking->customer_id() ); $service = $this->services->find_by_id( $booking->service_id() ); $agent = null !== $booking->agent_id() ? $this->agents->find_by_id( (int) $booking->agent_id() ) : null; $location = null !== $booking->location_id() ? $this->locations->find_by_id( (int) $booking->location_id() ) : null; $tz = new DateTimeZone( $booking->timezone()->to_string() ); $start = $booking->start_utc()->setTimezone( $tz ); $end = $booking->end_utc()->setTimezone( $tz ); $price = $this->format_money( $booking->price()->amount_minor(), $booking->price()->currency() ); $site_name = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : 'Bookline'; $site_url = function_exists( 'home_url' ) ? (string) home_url( '/' ) : 'https://example.test/'; $address_parts = []; if ( null !== $location ) { $address_parts = array_filter( [ $location->address_line1(), $location->address_line2(), $location->city(), $location->postcode(), ] ); } $address = implode( ', ', $address_parts ); $manage = rtrim( $site_url, '/' ) . '/bookline/manage/' . $booking->uuid()->to_string(); $ics_uri = 'data:text/calendar;charset=utf-8,' . rawurlencode( $this->ics->generate( $booking->uuid()->to_string() . '@bookline', null !== $service ? $service->name() : 'Booking', null !== $service ? $service->name() : 'Booking', $booking->start_utc(), $booking->end_utc(), '' !== $address ? $address : null ) ); $links = $this->calendars->build( null !== $service ? $service->name() : 'Booking', null !== $service ? $service->name() : 'Booking', $booking->start_utc(), $booking->end_utc(), '' !== $address ? $address : null, $ics_uri ); $first = null !== $customer ? $customer->first_name() : ''; $last = null !== $customer ? (string) $customer->last_name() : ''; return new VariableContext( [ 'booking.id' => (string) (int) $booking->id(), 'booking.uuid' => $booking->uuid()->to_string(), 'booking.status' => $booking->status()->value, 'booking.start_time' => $start->format( 'H:i' ), 'booking.end_time' => $end->format( 'H:i' ), 'booking.start_date' => $start->format( 'j F Y' ), 'booking.timezone' => $booking->timezone()->to_string(), 'booking.attendees' => (string) $booking->attendees(), 'booking.price' => $price, 'booking.notes' => (string) ( $booking->notes() ?? '' ), 'service.name' => null !== $service ? $service->name() : '', 'service.slug' => null !== $service ? $service->slug() : '', 'agent.name' => null !== $agent ? $agent->display_name() : '', 'agent.email' => null !== $agent ? $agent->email()->to_string() : '', 'customer.first_name' => $first, 'customer.last_name' => $last, 'customer.full_name' => trim( $first . ' ' . $last ), 'customer.email' => null !== $customer ? $customer->email()->to_string() : '', 'customer.phone' => null !== $customer && null !== $customer->phone() ? $customer->phone()->to_string() : '', 'location.name' => null !== $location ? $location->name() : '', 'location.address' => $address, 'order.id' => (string) (int) ( $booking->order_id() ?? 0 ), 'order.total' => $price, 'payment.status' => '', 'payment.amount' => '', 'site.name' => $site_name, 'site.url' => $site_url, 'links.manage' => $manage, 'links.reschedule' => $manage . '?action=reschedule', 'links.cancel' => $manage . '?action=cancel', 'links.ics' => $manage . '/calendar.ics', 'links.review' => $manage . '?action=review', 'links.calendar.google' => $links['google'], 'links.calendar.outlook' => $links['outlook'], 'links.calendar.office365' => $links['office365'], 'links.calendar.yahoo' => $links['yahoo'], 'links.calendar.apple' => $links['apple'], ] ); } /** * Resolve recipient for an automation send step. * * @param AutomationRecipient $role Recipient role. * @param int|null $booking_id Booking id when known. * @param int|null $customer_id Customer id when known. * @param AutomationContext $ctx Run context. */ private function resolve_automation_recipient( AutomationRecipient $role, ?int $booking_id, ?int $customer_id, AutomationContext $ctx, ): ?Recipient { if ( AutomationRecipient::Admin === $role ) { return $this->admin_recipient(); } if ( AutomationRecipient::Agent === $role ) { if ( null !== $booking_id ) { $booking = $this->bookings->find_by_id( $booking_id ); if ( null !== $booking ) { return $this->agent_recipient( $booking ); } } $agent_id = $ctx->field_int( 'agent_id' ); if ( $agent_id > 0 ) { $agent = $this->agents->find_by_id( $agent_id ); if ( null !== $agent ) { return new Recipient( $agent->email()->to_string(), $agent->display_name() ); } } return null; } if ( null !== $booking_id ) { $booking = $this->bookings->find_by_id( $booking_id ); if ( null !== $booking ) { return $this->customer_recipient( $booking ); } } if ( null !== $customer_id ) { $customer = $this->customers->find_by_id( $customer_id ); if ( null !== $customer ) { return new Recipient( $customer->email()->to_string(), trim( $customer->first_name() . ' ' . (string) $customer->last_name() ) ); } } return null; } /** * Variable context for customer-only automations. * * @param \Bookline\Domain\Customer\Customer $customer Customer. */ private function build_context_for_customer( Customer $customer ): VariableContext { $site_name = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : 'Bookline'; $site_url = function_exists( 'home_url' ) ? (string) home_url( '/' ) : 'https://example.test/'; $first = $customer->first_name(); $last = (string) $customer->last_name(); return new VariableContext( [ 'customer.first_name' => $first, 'customer.last_name' => $last, 'customer.full_name' => trim( $first . ' ' . $last ), 'customer.email' => $customer->email()->to_string(), 'customer.phone' => null !== $customer->phone() ? $customer->phone()->to_string() : '', 'site.name' => $site_name, 'site.url' => $site_url, ] ); } /** * Resolve recipient for a template role. * * @param Booking $booking Booking. * @param RecipientRole $role Role. * @param string|null $custom_recipient Custom address. * @param string $channel email, sms or whatsapp. */ private function resolve_recipient( Booking $booking, RecipientRole $role, ?string $custom_recipient, string $channel = 'email' ): ?Recipient { $phone = in_array( $channel, [ 'sms', 'whatsapp' ], true ); return match ( $role ) { RecipientRole::Customer => $this->customer_recipient( $booking, $phone ), RecipientRole::Agent => $this->agent_recipient( $booking, $phone ), RecipientRole::Admin => $phone ? null : $this->admin_recipient(), RecipientRole::Custom => ( null !== $custom_recipient && '' !== $custom_recipient ) ? new Recipient( $custom_recipient ) : null, }; } /** * Customer recipient. * * @param Booking $booking Booking. * @param bool $phone Use E.164 instead of email. */ private function customer_recipient( Booking $booking, bool $phone = false ): ?Recipient { $customer = $this->customers->find_by_id( $booking->customer_id() ); if ( null === $customer ) { return null; } $name = trim( $customer->first_name() . ' ' . (string) $customer->last_name() ); if ( $phone ) { if ( null === $customer->phone() ) { return null; } return new Recipient( $customer->phone()->to_string(), $name ); } return new Recipient( $customer->email()->to_string(), $name ); } /** * Agent recipient. * * @param Booking $booking Booking. * @param bool $phone Use E.164 instead of email. */ private function agent_recipient( Booking $booking, bool $phone = false ): ?Recipient { if ( null === $booking->agent_id() ) { return null; } $agent = $this->agents->find_by_id( (int) $booking->agent_id() ); if ( null === $agent ) { return null; } if ( $phone ) { if ( null === $agent->phone() ) { return null; } return new Recipient( $agent->phone()->to_string(), $agent->display_name() ); } return new Recipient( $agent->email()->to_string(), $agent->display_name() ); } /** * Admin recipient. */ private function admin_recipient(): ?Recipient { $email = (string) $this->settings->get( 'notifications.from_email' ); if ( '' === $email && function_exists( 'get_option' ) ) { $email = (string) get_option( 'admin_email' ); } if ( '' === $email ) { return null; } $name = (string) $this->settings->get( 'notifications.from_name' ); return new Recipient( $email, '' !== $name ? $name : null ); } /** * Allow extensions to alter the resolved variable bag. * * @param VariableBag $bag Bag. * @param VariableContext $context Context. */ private function filter_variable_bag( VariableBag $bag, VariableContext $context ): VariableBag { if ( ! function_exists( 'apply_filters' ) ) { return $bag; } /** * Filter the resolved notification variable bag. * * @param VariableBag $bag Bag. * @param VariableContext $context Context. * * @since 0.1.0 */ $filtered = apply_filters( 'bookline/notification/variable_bag', $bag, $context ); return $filtered instanceof VariableBag ? $filtered : $bag; } /** * Format money for templates. * * @param int $minor Minor units. * @param string $currency Currency. */ private function format_money( int $minor, string $currency ): string { $major = number_format( $minor / 100, 2, '.', ',' ); return match ( strtoupper( $currency ) ) { 'GBP' => '£' . $major, 'EUR' => '€' . $major, 'USD' => '$' . $major, default => $currency . ' ' . $major, }; } }