upskill-event-manager/includes/class-hvac-slack-notifications.php
ben 25bf5d98e1 feat(slack): Add Slack notifications for registrations, tickets, and events
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>
2026-02-20 13:50:08 -04:00

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!']);
}
}
}