$text, 'blocks' => $blocks, ]); $response = wp_remote_post($webhook_url, [ 'body' => $payload, 'headers' => ['Content-Type' => 'application/json'], 'timeout' => 5, 'blocking' => $blocking, ]); if ($blocking) { if (is_wp_error($response)) { error_log('[HVAC Slack] Send failed: ' . $response->get_error_message()); return false; } $code = wp_remote_retrieve_response_code($response); if ($code !== 200) { $body = wp_remote_retrieve_body($response); error_log("[HVAC Slack] Slack returned HTTP {$code}: {$body}"); return false; } return true; } // Non-blocking — optimistically assume success if (is_wp_error($response)) { error_log('[HVAC Slack] Non-blocking send error: ' . $response->get_error_message()); } return true; } // ------------------------------------------------------------------ // Registration notification // ------------------------------------------------------------------ /** * Send a Slack notification for a new trainer registration. * * @param int $user_id Newly created user ID. * @param array $submitted_data Raw form data from registration. */ public static function notify_new_registration(int $user_id, array $submitted_data): void { $first_name = sanitize_text_field($submitted_data['first_name'] ?? 'N/A'); $last_name = sanitize_text_field($submitted_data['last_name'] ?? ''); $full_name = trim("{$first_name} {$last_name}") ?: 'N/A'; $role = sanitize_text_field($submitted_data['role'] ?? $submitted_data['trainer_type'] ?? 'Trainer'); $business_name = sanitize_text_field($submitted_data['business_name'] ?? $submitted_data['company'] ?? 'N/A'); $business_type = sanitize_text_field($submitted_data['business_type'] ?? 'N/A'); $admin_url = admin_url("user-edit.php?user_id={$user_id}"); $text = "New Trainer Registration: {$full_name} ({$role})"; // Profile image accessory (optional) $accessory = null; $image_id = get_user_meta($user_id, 'profile_image_id', true); if ($image_id) { $image_url = wp_get_attachment_image_url((int) $image_id, 'thumbnail'); if ($image_url) { $accessory = [ 'type' => 'image', 'image_url' => $image_url, 'alt_text' => $full_name, ]; } } $fields_section = [ 'type' => 'section', 'fields' => [ ['type' => 'mrkdwn', 'text' => "*Name:*\n{$full_name}"], ['type' => 'mrkdwn', 'text' => "*Role:*\n{$role}"], ['type' => 'mrkdwn', 'text' => "*Organization:*\n{$business_name}"], ['type' => 'mrkdwn', 'text' => "*Business Type:*\n{$business_type}"], ], ]; if ($accessory) { $fields_section['accessory'] = $accessory; } $blocks = [ [ 'type' => 'header', 'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x86\x95 New Trainer Registration", 'emoji' => true], ], $fields_section, [ 'type' => 'actions', 'elements' => [ [ 'type' => 'button', 'text' => ['type' => 'plain_text', 'text' => 'View in WordPress'], 'url' => $admin_url, 'style' => 'primary', ], ], ], ]; self::send($text, $blocks); } // ------------------------------------------------------------------ // Ticket purchase notification // ------------------------------------------------------------------ /** * Hook callback for transition_post_status. * * @param string $new_status New post status. * @param string $old_status Previous post status. * @param WP_Post $post Post object. */ public static function on_order_status_change(string $new_status, string $old_status, \WP_Post $post): void { // Guard 1: correct post type if ($post->post_type !== 'tec_tc_order') { return; } // Guard 2: transitioning INTO completed if ($new_status !== 'tec-tc-completed') { return; } // Guard 3: not already completed (prevents re-saves) if ($old_status === 'tec-tc-completed') { return; } // Atomic idempotency: add_post_meta with $unique=true prevents races if (!add_post_meta($post->ID, '_hvac_slack_ticket_notified', '1', true)) { return; } self::notify_ticket_purchase($post->ID); } /** * Send a Slack notification for a completed ticket purchase. * * @param int $order_id TEC Tickets Commerce order post ID. */ public static function notify_ticket_purchase(int $order_id): void { $purchaser_name = get_post_meta($order_id, '_tec_tc_order_purchaser_name', true) ?: 'N/A'; $purchaser_email = get_post_meta($order_id, '_tec_tc_order_purchaser_email', true) ?: 'N/A'; $order_items = get_post_meta($order_id, '_tec_tc_order_items', true); $gateway = get_post_meta($order_id, '_tec_tc_order_gateway', true) ?: 'Unknown'; $total_qty = 0; $total_price = 0.0; $event_title = 'N/A'; if (is_array($order_items)) { foreach ($order_items as $item) { $qty = (int) ($item['quantity'] ?? 1); $total_qty += $qty; $total_price += (float) ($item['sub_total'] ?? $item['price'] ?? 0) * $qty; if (!empty($item['event_id']) && $event_title === 'N/A') { $title = get_the_title((int) $item['event_id']); if ($title) { $event_title = $title; } } } } $total_formatted = '$' . number_format($total_price, 2); $admin_url = admin_url("post.php?post={$order_id}&action=edit"); $text = "New Ticket Purchase: {$purchaser_name} — {$total_qty} ticket(s) for {$event_title} ({$total_formatted})"; $blocks = [ [ 'type' => 'header', 'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x8E\x9F\xEF\xB8\x8F New Ticket Purchase", 'emoji' => true], ], [ 'type' => 'section', 'fields' => [ ['type' => 'mrkdwn', 'text' => "*Purchaser:*\n{$purchaser_name}"], ['type' => 'mrkdwn', 'text' => "*Email:*\n{$purchaser_email}"], ['type' => 'mrkdwn', 'text' => "*Event:*\n{$event_title}"], ['type' => 'mrkdwn', 'text' => "*Tickets:*\n{$total_qty}"], ], ], [ 'type' => 'section', 'fields' => [ ['type' => 'mrkdwn', 'text' => "*Total:*\n{$total_formatted}"], ], ], [ 'type' => 'context', 'elements' => [ ['type' => 'mrkdwn', 'text' => "via {$gateway}"], ], ], [ 'type' => 'actions', 'elements' => [ [ 'type' => 'button', 'text' => ['type' => 'plain_text', 'text' => 'View Order'], 'url' => $admin_url, 'style' => 'primary', ], ], ], ]; self::send($text, $blocks); } // ------------------------------------------------------------------ // Event notifications // ------------------------------------------------------------------ /** * Build common event Block Kit fields from an event post ID. * * @param int $event_id The tribe_events post ID. * @return array{text: string, fields: array, admin_url: string, event_title: string} */ private static function build_event_fields(int $event_id): array { $event = get_post($event_id); $event_title = $event ? $event->post_title : 'N/A'; $start_date = get_post_meta($event_id, '_EventStartDate', true); $end_date = get_post_meta($event_id, '_EventEndDate', true); $venue_id = get_post_meta($event_id, '_EventVenueID', true); $venue_name = $venue_id ? get_the_title((int) $venue_id) : 'N/A'; $date_display = 'N/A'; if ($start_date) { $date_display = wp_date('M j, Y g:ia', strtotime($start_date)); if ($end_date) { $date_display .= ' — ' . wp_date('g:ia', strtotime($end_date)); } } // Get trainer/author info $author_id = $event ? (int) $event->post_author : 0; $author_name = $author_id ? get_the_author_meta('display_name', $author_id) : 'N/A'; $admin_url = admin_url("post.php?post={$event_id}&action=edit"); $front_url = get_permalink($event_id) ?: $admin_url; $fields = [ ['type' => 'mrkdwn', 'text' => "*Event:*\n{$event_title}"], ['type' => 'mrkdwn', 'text' => "*Trainer:*\n{$author_name}"], ['type' => 'mrkdwn', 'text' => "*Date:*\n{$date_display}"], ['type' => 'mrkdwn', 'text' => "*Venue:*\n{$venue_name}"], ]; return [ 'event_title' => $event_title, 'author_name' => $author_name, 'fields' => $fields, 'admin_url' => $admin_url, 'front_url' => $front_url, ]; } /** * Called when a trainer submits an event via TEC Community Events form. * Hooked to `hvac_tec_event_saved`. * * @param int $event_id The saved event post ID. */ public static function notify_event_submitted(int $event_id): void { // Atomic lock: add_post_meta with $unique=true returns false if key already exists. // Prevents duplicate sends on edits and concurrent requests. if (!add_post_meta($event_id, '_hvac_slack_event_notified', '1', true)) { return; } $data = self::build_event_fields($event_id); $text = "New Event Submitted: {$data['event_title']} by {$data['author_name']}"; $blocks = [ [ 'type' => 'header', 'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x93\x9D Event Submitted by Trainer", 'emoji' => true], ], [ 'type' => 'section', 'fields' => $data['fields'], ], [ 'type' => 'actions', 'elements' => [ [ 'type' => 'button', 'text' => ['type' => 'plain_text', 'text' => 'View Event'], 'url' => $data['admin_url'], 'style' => 'primary', ], ], ], ]; self::send($text, $blocks); } /** * Hook callback for transition_post_status on tribe_events. * Fires when an admin publishes an event (including from draft/pending). * * @param string $new_status New post status. * @param string $old_status Previous post status. * @param WP_Post $post Post object. */ public static function on_event_status_change(string $new_status, string $old_status, \WP_Post $post): void { if ($post->post_type !== 'tribe_events') { return; } // Only notify when transitioning INTO publish if ($new_status !== 'publish' || $old_status === 'publish') { return; } // Atomic lock: prevents double-notify from trainer submission hook or concurrent requests if (!add_post_meta($post->ID, '_hvac_slack_event_notified', '1', true)) { return; } $data = self::build_event_fields($post->ID); $text = "Event Published: {$data['event_title']}"; $blocks = [ [ 'type' => 'header', 'text' => ['type' => 'plain_text', 'text' => "\xE2\x9C\x85 Event Published", 'emoji' => true], ], [ 'type' => 'section', 'fields' => $data['fields'], ], [ 'type' => 'actions', 'elements' => [ [ 'type' => 'button', 'text' => ['type' => 'plain_text', 'text' => 'View Event'], 'url' => $data['front_url'], ], [ 'type' => 'button', 'text' => ['type' => 'plain_text', 'text' => 'Edit in WP'], 'url' => $data['admin_url'], ], ], ], ]; self::send($text, $blocks); } // ------------------------------------------------------------------ // Test notification (AJAX) // ------------------------------------------------------------------ /** * AJAX handler: send a test notification to verify webhook config. * Uses blocking mode so the admin UI gets real success/failure feedback. */ public static function send_test_notification(): void { if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'Unauthorized'], 403); } check_ajax_referer('hvac_test_slack_webhook', 'nonce'); $env = function_exists('hvac_is_staging_environment') && hvac_is_staging_environment() ? 'Staging' : 'Production'; $site_url = home_url('/'); $text = "Test notification from HVAC Community Events ({$env})"; $blocks = [ [ 'type' => 'header', 'text' => ['type' => 'plain_text', 'text' => "\xE2\x9C\x85 Slack Integration Test", 'emoji' => true], ], [ 'type' => 'section', 'text' => [ 'type' => 'mrkdwn', 'text' => "This is a test notification from *HVAC Community Events*.\nIf you see this, your webhook is working correctly.", ], ], [ 'type' => 'section', 'fields' => [ ['type' => 'mrkdwn', 'text' => "*Environment:*\n{$env}"], ['type' => 'mrkdwn', 'text' => "*Site:*\n{$site_url}"], ], ], [ 'type' => 'context', 'elements' => [ ['type' => 'mrkdwn', 'text' => 'Sent at ' . current_time('Y-m-d H:i:s')], ], ], ]; $result = self::send($text, $blocks, blocking: true); if ($result === null) { wp_send_json_error(['message' => 'No webhook URL configured.']); } elseif ($result === false) { wp_send_json_error(['message' => 'Slack returned an error. Check the webhook URL and try again.']); } else { wp_send_json_success(['message' => 'Test notification sent successfully!']); } } }