* * @throws DomainException When missing. */ public function get( int $id ): array { return $this->to_editor_array( $this->require( $id ) ); } /** * Patch subject, body and enabled flag. * * @param int $id Template id. * @param array $payload Fields. * @return array * * @throws DomainException When missing. */ public function update( int $id, array $payload ): array { $template = $this->require( $id ); $subject = array_key_exists( 'subject', $payload ) ? $this->nullable_string( $payload['subject'] ?? null ) : $template->subject(); $body = array_key_exists( 'body', $payload ) ? $this->nullable_string( $payload['body'] ?? null ) : $template->body(); $enabled = array_key_exists( 'enabled', $payload ) ? (bool) $payload['enabled'] : ( array_key_exists( 'is_enabled', $payload ) ? (bool) $payload['is_enabled'] : $template->is_enabled() ); if ( array_key_exists( 'body_plain', $payload ) ) { $plain = $this->nullable_string( $payload['body_plain'] ?? null ); $body = $this->embed_plain( $body, $plain ); } $updated = $template->with_content( $subject, $body, $enabled, $this->clock->now() ); $this->templates->save( $updated ); return $this->to_editor_array( $updated ); } /** * Variable palette for the editor. * * @return array{items: list>} */ public function variables(): array { $items = []; foreach ( $this->variables->all() as $variable ) { $items[] = [ 'key' => $variable->key(), 'group' => $variable->group(), 'label' => $variable->label(), 'description' => $variable->description(), 'token' => '{{' . $variable->key() . '}}', ]; } return [ 'items' => $items ]; } /** * Render subject and body against a real booking or sample data. * * @param int $id Template id. * @param int|null $booking_id Optional booking. * @return array * * @throws DomainException When missing. */ public function preview( int $id, ?int $booking_id = null ): array { $template = $this->require( $id ); $context = $this->context_for_preview( $booking_id ); $bag = $this->variables->resolve( $context ); $subject = $this->renderer->replace( (string) $template->subject(), $bag, EscapeFormat::Text ); $html = $this->html_body( $template->body() ); $body = $this->renderer->replace( $html, $bag, EscapeFormat::Html ); $plain = $this->renderer->replace( $this->plain_body( $template->body() ), $bag, EscapeFormat::Text ); return [ 'subject' => $subject, 'body' => $body, 'body_plain' => $plain, 'sample' => null === $booking_id || $booking_id < 1, 'booking_id' => $booking_id, ]; } /** * Queue a test send to the current operator (drafted unless outbound is allowed). * * @param int $id Template id. * @return array * * @throws DomainException When missing or the operator has no email. */ public function test_send( int $id ): array { $template = $this->require( $id ); $address = $this->current_user_email(); if ( '' === $address ) { throw new DomainException( 'notification.test_no_email', 'Your WordPress user needs an email address to receive a test send.' ); } $preview = $this->preview( $id, null ); $now = $this->clock->now(); $status = OutboundGuard::allows_outbound() ? NotificationStatus::Queued : NotificationStatus::Drafted; $subject = '[TEST] ' . (string) $preview['subject']; $body = (string) $preview['body']; $entry = new NotificationLogEntry( null, Uuid::generate(), null, null, $template->key(), $template->channel(), $address, $subject, $body, $status, $now, null, 0, null, null, 'test:' . (string) $id . ':' . $now->format( 'YmdHis' ), null, $now, $now, ); $this->logs->save( $entry ); if ( NotificationStatus::Queued === $status && null !== $entry->id() ) { $this->dispatcher->process_log( (int) $entry->id() ); } return [ 'ok' => true, 'notification_log_id' => $entry->id(), 'status' => $entry->status()->value, 'recipient' => $address, ]; } /** * Require a stored template. * * @param int $id Id. * * @throws DomainException When missing. */ private function require( int $id ): NotificationTemplate { $template = $this->templates->find_by_id( $id ); if ( null === $template ) { throw new DomainException( 'notification_template.not_found', 'Notification template not found.' ); } return $template; } /** * Editor payload. * * @param NotificationTemplate $template Template. * @return array */ private function to_editor_array( NotificationTemplate $template ): array { return [ 'id' => $template->id(), 'key' => $template->key(), 'name' => ucwords( str_replace( [ '_', '.' ], ' ', $template->key() ) ), 'channel' => $template->channel(), 'recipient' => $template->recipient()->value, 'locale' => $template->locale(), 'subject' => $template->subject(), 'body' => $this->html_body( $template->body() ), 'body_plain' => $this->plain_body( $template->body() ), 'enabled' => $template->is_enabled(), ]; } /** * Build preview context. * * @param int|null $booking_id Booking id. */ private function context_for_preview( ?int $booking_id ): VariableContext { if ( null !== $booking_id && $booking_id > 0 ) { $booking = $this->bookings->find_by_id( $booking_id ); if ( null !== $booking ) { return $this->dispatcher->build_context( $booking ); } } $recent = $this->bookings->find_for_admin_calendar( $this->clock->now()->modify( '-90 days' ), $this->clock->now()->modify( '+90 days' ), null ); if ( [] !== $recent ) { return $this->dispatcher->build_context( $recent[0] ); } return $this->sample_context(); } /** * Sample bag when no booking exists. */ private function sample_context(): VariableContext { $values = []; foreach ( $this->variables->all() as $variable ) { $values[ $variable->key() ] = $this->sample_value( $variable->key() ); } return new VariableContext( $values ); } /** * Example value for a variable key. * * @param string $key Key. */ private function sample_value( string $key ): string { return match ( $key ) { 'booking.id' => '42', 'booking.uuid' => 'sample-booking', 'booking.status' => 'confirmed', 'booking.start_time' => '10:00', 'booking.end_time' => '11:00', 'booking.start_date' => ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->format( 'j F Y' ), 'booking.timezone' => 'Europe/London', 'booking.attendees' => '1', 'booking.price' => '£50.00', 'booking.notes' => '', 'service.name' => 'Consultation', 'service.slug' => 'consultation', 'agent.name' => 'Alex Taylor', 'agent.email' => 'alex@example.test', 'customer.first_name' => 'Sam', 'customer.last_name' => 'Lee', 'customer.full_name' => 'Sam Lee', 'customer.email' => 'sam@example.test', 'customer.phone' => '+447700900123', 'location.name' => 'Studio', 'location.address' => '1 High Street, London', 'order.id' => '7', 'order.total' => '£50.00', 'payment.status' => 'succeeded', 'payment.amount' => '£50.00', 'site.name' => function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : 'Bookline', 'site.url' => function_exists( 'home_url' ) ? (string) home_url( '/' ) : 'https://example.test/', default => 'https://example.test/' . str_replace( '.', '/', $key ), }; } /** * Current operator email. */ private function current_user_email(): string { if ( ! function_exists( 'wp_get_current_user' ) ) { return ''; } $user = wp_get_current_user(); return is_string( $user->user_email ) ? trim( $user->user_email ) : ''; } /** * Optional string. * * @param mixed $value Raw. */ private function nullable_string( mixed $value ): ?string { if ( ! is_string( $value ) ) { return null; } $trimmed = trim( $value ); return '' === $trimmed ? null : $trimmed; } /** * HTML body without the plain-text marker. * * @param string|null $body Stored body. */ private function html_body( ?string $body ): string { if ( null === $body || '' === $body ) { return ''; } if ( str_contains( $body, '/s', '', $body ) ); } return $body; } /** * Plain-text fallback. * * @param string|null $body Stored body. */ private function plain_body( ?string $body ): string { if ( null === $body || '' === $body ) { return ''; } if ( preg_match( '//s', $body, $matches ) ) { return html_entity_decode( trim( $matches[1] ), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); } $html = $this->html_body( $body ); $text = function_exists( 'wp_strip_all_tags' ) ? wp_strip_all_tags( $html ) : trim( (string) preg_replace( '/<[^>]+>/', ' ', $html ) ); return trim( $text ); } /** * Persist HTML plus optional plain fallback. * * @param string|null $html HTML. * @param string|null $plain Plain. */ private function embed_plain( ?string $html, ?string $plain ): ?string { $clean = $this->html_body( $html ); if ( null === $plain || '' === $plain ) { return '' === $clean ? null : $clean; } $encoded = htmlspecialchars( $plain, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); $combined = $clean . "\n'; return $combined; } }