Instant Slack alerts via Incoming Webhook with Block Kit rich formatting: - New trainer registrations (name, role, org, business type, photo) - Ticket purchases (purchaser, event, count, total, gateway) - Events submitted by trainers via TEC Community Events form - Events published by admins (draft/pending → publish) Settings UI with webhook URL field, validation, and test button. Non-blocking sends so Slack failures never affect user flows. Atomic add_post_meta idempotency guards prevent duplicate sends. Code reviewed by GPT-5 (Codex) — all 5 findings addressed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
485 lines
18 KiB
PHP
485 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Slack notification utility for trainer registrations and ticket purchases.
|
|
*
|
|
* Uses Slack Incoming Webhooks with Block Kit rich formatting.
|
|
* All sends are non-blocking (fire-and-forget) so Slack failures
|
|
* never affect registration or checkout flows.
|
|
*/
|
|
class HVAC_Slack_Notifications {
|
|
|
|
/** @var bool Prevents duplicate hook registration */
|
|
private static bool $initialized = false;
|
|
|
|
/**
|
|
* Register hooks. Called once from HVAC_Plugin::initializeSecondaryComponents().
|
|
*/
|
|
public static function init(): void {
|
|
if (self::$initialized) {
|
|
return;
|
|
}
|
|
self::$initialized = true;
|
|
|
|
// Ticket purchase: fires on any post status transition
|
|
add_action('transition_post_status', [__CLASS__, 'on_order_status_change'], 10, 3);
|
|
|
|
// Event published by admin (any tribe_events post transitioning to publish)
|
|
add_action('transition_post_status', [__CLASS__, 'on_event_status_change'], 10, 3);
|
|
|
|
// Event submitted by trainer via TEC Community Events form
|
|
add_action('hvac_tec_event_saved', [__CLASS__, 'notify_event_submitted'], 10, 1);
|
|
|
|
// AJAX: test webhook from settings page
|
|
add_action('wp_ajax_hvac_test_slack_webhook', [__CLASS__, 'send_test_notification']);
|
|
}
|
|
|
|
/**
|
|
* Send a message to Slack via Incoming Webhook.
|
|
*
|
|
* @param string $text Plain-text fallback (required by Slack).
|
|
* @param array $blocks Block Kit blocks for rich layout.
|
|
* @param bool $blocking Whether to wait for the response.
|
|
* @return bool|null True on success, false on failure, null if disabled.
|
|
*/
|
|
private static function send(string $text, array $blocks, bool $blocking = false): ?bool {
|
|
$webhook_url = get_option('hvac_slack_webhook_url', '');
|
|
|
|
if (empty($webhook_url)) {
|
|
return null; // Disabled — no webhook configured
|
|
}
|
|
|
|
// Validate URL: must be https://hooks.slack.com/services/...
|
|
$scheme = parse_url($webhook_url, PHP_URL_SCHEME);
|
|
$host = parse_url($webhook_url, PHP_URL_HOST);
|
|
$path = parse_url($webhook_url, PHP_URL_PATH) ?: '';
|
|
if ($scheme !== 'https' || $host !== 'hooks.slack.com' || !str_starts_with($path, '/services/')) {
|
|
error_log('[HVAC Slack] Rejected webhook URL: ' . $webhook_url);
|
|
return false;
|
|
}
|
|
|
|
$payload = wp_json_encode([
|
|
'text' => $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!']);
|
|
}
|
|
}
|
|
}
|