diff --git a/Status.md b/Status.md
index a6d083b2..b00d87e3 100644
--- a/Status.md
+++ b/Status.md
@@ -1,6 +1,6 @@
# HVAC Community Events - Project Status
-**Last Updated:** February 9, 2026
+**Last Updated:** February 20, 2026
**Version:** 2.2.18 (Deployed to Production)
---
@@ -19,7 +19,39 @@
---
-## CURRENT SESSION - MARKER VISIBILITY TOGGLE CHECKBOX REFACTOR (Feb 9, 2026)
+## CURRENT SESSION - SLACK NOTIFICATIONS (Feb 20, 2026)
+
+### Status: COMPLETE - Deployed to Production
+
+**Objective:** Add Slack notifications for trainer registrations, ticket purchases, and event submissions/publishes via Incoming Webhook with Block Kit rich formatting.
+
+### Features
+- **New Trainer Registration** — name, role, organization, business type, profile photo, "View in WordPress" button
+- **Ticket Purchase** — purchaser, email, event, ticket count, total, payment gateway, "View Order" button
+- **Event Submitted by Trainer** — event title, trainer, date, venue, "View Event" button
+- **Event Published by Admin** — same fields, "View Event" + "Edit in WP" buttons
+- **Settings UI** — Webhook URL field (password type), "Send Test Notification" button with AJAX feedback
+- **Test message** includes environment (Staging/Production) and site URL
+
+### Design Decisions
+- Non-blocking `wp_remote_post` for all real notifications; blocking only for test button
+- Atomic `add_post_meta(..., true)` for idempotency (prevents races and duplicate sends)
+- Webhook URL validated at save-time and send-time: `https` + `hooks.slack.com` + `/services/` path
+- Graceful degradation: empty webhook = disabled, missing meta = "N/A" fields, no exceptions surface to users
+- Code reviewed by GPT-5 (Codex) — all 5 findings fixed before production deploy
+
+### Files Modified
+
+| File | Change |
+|------|--------|
+| `includes/class-hvac-slack-notifications.php` | **NEW** — static utility class with 4 notification types + test handler |
+| `includes/class-hvac-settings.php` | Slack webhook URL setting + validation + test button |
+| `includes/class-hvac-plugin.php` | Include file + `init()` call in `initializeSecondaryComponents()` |
+| `includes/class-hvac-registration.php` | One-line call to `notify_new_registration()` after admin email |
+
+---
+
+## PREVIOUS SESSION - MARKER VISIBILITY TOGGLE CHECKBOX REFACTOR (Feb 9, 2026)
### Status: COMPLETE - Deployed to Production (v2.2.18)
diff --git a/includes/class-hvac-plugin.php b/includes/class-hvac-plugin.php
index 13d558aa..e0a547e4 100644
--- a/includes/class-hvac-plugin.php
+++ b/includes/class-hvac-plugin.php
@@ -255,6 +255,7 @@ final class HVAC_Plugin {
'class-attendee-profile.php',
'class-hvac-page-content-fixer.php',
'class-hvac-page-content-manager.php',
+ 'class-hvac-slack-notifications.php',
];
// Find a Trainer feature files
@@ -759,6 +760,11 @@ final class HVAC_Plugin {
if (class_exists('HVAC_Announcements_Display')) {
HVAC_Announcements_Display::get_instance();
}
+
+ // Initialize Slack notifications (registration + ticket purchase hooks)
+ if (class_exists('HVAC_Slack_Notifications')) {
+ HVAC_Slack_Notifications::init();
+ }
error_log('HVAC Plugin: Checking if HVAC_Announcements_Admin class exists: ' . (class_exists('HVAC_Announcements_Admin') ? 'YES' : 'NO'));
if (class_exists('HVAC_Announcements_Admin')) {
error_log('HVAC Plugin: Instantiating HVAC_Announcements_Admin...');
diff --git a/includes/class-hvac-registration.php b/includes/class-hvac-registration.php
index baf8685c..27c558d8 100644
--- a/includes/class-hvac-registration.php
+++ b/includes/class-hvac-registration.php
@@ -162,6 +162,11 @@ class HVAC_Registration {
$this->send_admin_notification($user_id, $submitted_data);
}
+ // Slack notification (non-blocking, fire-and-forget)
+ if (class_exists('HVAC_Slack_Notifications')) {
+ HVAC_Slack_Notifications::notify_new_registration($user_id, $submitted_data);
+ }
+
// --- Success Redirect ---
$success_redirect_url = home_url('/registration-pending/'); // URL from E2E test
diff --git a/includes/class-hvac-settings.php b/includes/class-hvac-settings.php
index 57bd2267..6db855b4 100644
--- a/includes/class-hvac-settings.php
+++ b/includes/class-hvac-settings.php
@@ -63,7 +63,15 @@ class HVAC_Settings {
public function register_settings() {
register_setting('hvac_ce_options', 'hvac_ce_options');
-
+ register_setting('hvac_ce_options', 'hvac_google_maps_api_key', [
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ]);
+ register_setting('hvac_ce_options', 'hvac_google_geocoding_api_key', [
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ]);
+
add_settings_section(
'hvac_ce_main',
__('HVAC Community Events Settings', 'hvac-ce'),
@@ -78,6 +86,136 @@ class HVAC_Settings {
'hvac-ce',
'hvac_ce_main'
);
+
+ // Google Maps API Settings Section
+ add_settings_section(
+ 'hvac_ce_google_maps',
+ __('Google Maps API Settings', 'hvac-ce'),
+ [$this, 'google_maps_section_callback'],
+ 'hvac-ce'
+ );
+
+ add_settings_field(
+ 'hvac_google_maps_api_key',
+ __('Maps JavaScript API Key', 'hvac-ce'),
+ [$this, 'maps_api_key_callback'],
+ 'hvac-ce',
+ 'hvac_ce_google_maps'
+ );
+
+ add_settings_field(
+ 'hvac_google_geocoding_api_key',
+ __('Geocoding API Key', 'hvac-ce'),
+ [$this, 'geocoding_api_key_callback'],
+ 'hvac-ce',
+ 'hvac_ce_google_maps'
+ );
+
+ // Slack Integration Section
+ register_setting('hvac_ce_options', 'hvac_slack_webhook_url', [
+ 'type' => 'string',
+ 'sanitize_callback' => [$this, 'sanitize_slack_webhook_url'],
+ ]);
+
+ add_settings_section(
+ 'hvac_ce_slack',
+ __('Slack Integration', 'hvac-ce'),
+ [$this, 'slack_section_callback'],
+ 'hvac-ce'
+ );
+
+ add_settings_field(
+ 'hvac_slack_webhook_url',
+ __('Webhook URL', 'hvac-ce'),
+ [$this, 'slack_webhook_url_callback'],
+ 'hvac-ce',
+ 'hvac_ce_slack'
+ );
+ }
+
+ /**
+ * Sanitize Slack webhook URL — only allow hooks.slack.com
+ */
+ public function sanitize_slack_webhook_url(mixed $value): string {
+ if (!is_string($value)) {
+ return get_option('hvac_slack_webhook_url', '');
+ }
+ $value = trim($value);
+ if ($value === '') {
+ return '';
+ }
+
+ $value = esc_url_raw($value);
+ $scheme = parse_url($value, PHP_URL_SCHEME);
+ $host = parse_url($value, PHP_URL_HOST);
+ $path = parse_url($value, PHP_URL_PATH) ?: '';
+
+ if ($scheme !== 'https' || $host !== 'hooks.slack.com' || !str_starts_with($path, '/services/')) {
+ add_settings_error(
+ 'hvac_slack_webhook_url',
+ 'invalid_url',
+ __('Slack webhook URL must be a valid https://hooks.slack.com/services/... URL.', 'hvac-ce'),
+ 'error'
+ );
+ return get_option('hvac_slack_webhook_url', ''); // keep old value
+ }
+
+ return $value;
+ }
+
+ public function slack_section_callback() {
+ echo '
' . __('Sends notifications for new trainer registrations and ticket purchases to a Slack channel.', 'hvac-ce') . '
';
+ }
+
+ public function slack_webhook_url_callback() {
+ $value = get_option('hvac_slack_webhook_url', '');
+ echo ' ';
+ echo '' . __('Create an Incoming Webhook in your Slack workspace and paste the URL here. Leave empty to disable.', 'hvac-ce') . '
';
+
+ if (!empty($value)) {
+ $nonce = wp_create_nonce('hvac_test_slack_webhook');
+ echo '';
+ echo '';
+ echo __('Send Test Notification', 'hvac-ce');
+ echo ' ';
+ echo ' ';
+ echo '
';
+ ?>
+
+ ';
- echo '' .
+ echo '
' .
__('Comma-separated list of emails to notify when new trainers register', 'hvac-ce') . '
';
}
+ public function google_maps_section_callback() {
+ echo '' . __('Configure Google Maps API keys for maps and geocoding functionality.', 'hvac-ce') . '
';
+ echo '' . __('You need two API keys: one for browser-side Maps JavaScript API (HTTP referrer restricted) and one for server-side Geocoding API (IP restricted).', 'hvac-ce') . '
';
+ }
+
+ public function maps_api_key_callback() {
+ $value = get_option('hvac_google_maps_api_key', '');
+ echo ' ';
+ echo '' . __('Browser-side API key for Google Maps JavaScript API. Should be HTTP referrer restricted to your domain.', 'hvac-ce') . '
';
+ }
+
+ public function geocoding_api_key_callback() {
+ $value = get_option('hvac_google_geocoding_api_key', '');
+ echo ' ';
+ echo '' . __('Server-side API key for Google Geocoding API. Should be IP restricted to your server IP address.', 'hvac-ce') . '
';
+
+ // Show current status
+ if (!empty($value)) {
+ echo '✓ ' . __('Geocoding API key is configured.', 'hvac-ce') . '
';
+
+ // Show batch geocode button
+ $this->render_batch_geocode_button();
+ } else {
+ echo '⚠ ' . __('Geocoding API key not set. Venue addresses will not be geocoded for map display.', 'hvac-ce') . '
';
+ }
+ }
+
+ /**
+ * Render batch geocode button and status
+ */
+ private function render_batch_geocode_button() {
+ // Count venues without coordinates
+ $venues_without_coords = get_posts([
+ 'post_type' => 'tribe_venue',
+ 'posts_per_page' => -1,
+ 'post_status' => 'publish',
+ 'fields' => 'ids',
+ 'meta_query' => [
+ 'relation' => 'AND',
+ [
+ 'key' => 'venue_latitude',
+ 'compare' => 'NOT EXISTS'
+ ],
+ [
+ 'key' => '_VenueLat',
+ 'compare' => 'NOT EXISTS'
+ ]
+ ]
+ ]);
+
+ $count = count($venues_without_coords);
+ $nonce = wp_create_nonce('hvac_batch_geocode_venues');
+
+ echo '';
+ echo '
' . __('Venue Geocoding Status', 'hvac-ce') . ' ';
+
+ if ($count > 0) {
+ echo '
' . sprintf(__('%d venues need geocoding.', 'hvac-ce'), $count) . '
';
+ echo '
';
+ echo __('Geocode All Venues', 'hvac-ce');
+ echo ' ';
+ echo '
';
+ } else {
+ echo '
✓ ' . __('All venues are geocoded.', 'hvac-ce') . '
';
+ }
+
+ echo '
';
+
+ // Render the mark as approved labs button
+ $this->render_mark_approved_button();
+
+ // Add inline JavaScript for the batch geocode button
+ ?>
+
+ 'tribe_venue',
+ 'posts_per_page' => -1,
+ 'post_status' => 'publish',
+ 'orderby' => 'title',
+ 'order' => 'ASC'
+ ]);
+
+ $nonce = wp_create_nonce('hvac_mark_venues_approved');
+
+ echo '';
+ echo '
' . __('measureQuick Approved Training Labs', 'hvac-ce') . ' ';
+ echo '
' . __('Select which venues should appear on the Find Training map as approved training labs.', 'hvac-ce') . '
';
+
+ if (empty($all_venues)) {
+ echo '
' . __('No venues found.', 'hvac-ce') . '
';
+ echo '
';
+ return;
+ }
+
+ // Build venue data
+ $venue_data = [];
+ $approved_count = 0;
+ $geocoded_count = 0;
+
+ foreach ($all_venues as $venue) {
+ $is_approved = has_term('mq-approved-lab', 'venue_type', $venue->ID);
+ $lat = get_post_meta($venue->ID, 'venue_latitude', true) ?: get_post_meta($venue->ID, '_VenueLat', true);
+ $lng = get_post_meta($venue->ID, 'venue_longitude', true) ?: get_post_meta($venue->ID, '_VenueLng', true);
+ $has_coords = !empty($lat) && !empty($lng);
+
+ $city = get_post_meta($venue->ID, '_VenueCity', true);
+ $state = get_post_meta($venue->ID, '_VenueState', true);
+ $location = trim($city . ($city && $state ? ', ' : '') . $state);
+
+ if ($is_approved) $approved_count++;
+ if ($has_coords) $geocoded_count++;
+
+ $venue_data[] = [
+ 'id' => $venue->ID,
+ 'title' => $venue->post_title,
+ 'location' => $location,
+ 'is_approved' => $is_approved,
+ 'has_coords' => $has_coords
+ ];
+ }
+
+ // Summary
+ echo '';
+ echo sprintf(__('%d venues total, %d approved, %d geocoded', 'hvac-ce'),
+ count($all_venues), $approved_count, $geocoded_count);
+ echo '
';
+
+ // Scrollable list
+ echo '';
+
+ // Save button
+ echo '';
+ echo __('Save Approved Labs', 'hvac-ce');
+ echo ' ';
+ echo ' ';
+
+ echo '';
+
+ // JavaScript
+ ?>
+
+ $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!']);
+ }
+ }
+}