| 1 |
<?php |
| 2 |
/** |
| 3 |
* Seeds the 8 default notification flows on first activation. |
| 4 |
* |
| 5 |
* @package Timetics |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Timetics\Core\Admin; |
| 9 |
|
| 10 |
use Ens\Flow\Flow; |
| 11 |
|
| 12 |
defined( 'ABSPATH' ) || exit; |
| 13 |
|
| 14 |
class Notification_Seeder { |
| 15 |
|
| 16 |
const SEEDED_OPTION = 'timetics_default_flows_seeded'; |
| 17 |
const SEEDED_VERSION = '2.0'; |
| 18 |
|
| 19 |
/** |
| 20 |
* Seed the 8 default flows as drafts. |
| 21 |
* |
| 22 |
* The legacy email system in Settings remains the active sender; these |
| 23 |
* flows ship as drafts (examples) so they never fire until a user |
| 24 |
* explicitly publishes one. |
| 25 |
*/ |
| 26 |
public static function maybe_seed() { |
| 27 |
self::migrate_bad_flows(); |
| 28 |
|
| 29 |
if ( get_option( self::SEEDED_OPTION ) === self::SEEDED_VERSION ) { |
| 30 |
return; |
| 31 |
} |
| 32 |
|
| 33 |
$existing = self::existing_flow_titles(); |
| 34 |
|
| 35 |
// Demote previously published default seeds so they stop firing. |
| 36 |
self::draft_published_seeds(); |
| 37 |
|
| 38 |
foreach ( self::get_default_flows() as $flow_def ) { |
| 39 |
// Skip if a flow with this name already exists (avoids duplicates |
| 40 |
// on upgrade); user-renamed flows are left untouched. |
| 41 |
if ( in_array( $flow_def['name'], $existing, true ) ) { |
| 42 |
continue; |
| 43 |
} |
| 44 |
self::create_flow( $flow_def ); |
| 45 |
} |
| 46 |
|
| 47 |
update_option( self::SEEDED_OPTION, self::SEEDED_VERSION, false ); |
| 48 |
} |
| 49 |
|
| 50 |
/** |
| 51 |
* Titles of all tt-flow posts currently in the DB (any status). |
| 52 |
* |
| 53 |
* @return string[] |
| 54 |
*/ |
| 55 |
private static function existing_flow_titles() { |
| 56 |
$posts = get_posts( array( |
| 57 |
'post_type' => 'tt-flow', |
| 58 |
'posts_per_page' => -1, |
| 59 |
'post_status' => 'any', |
| 60 |
) ); |
| 61 |
|
| 62 |
return wp_list_pluck( $posts, 'post_title' ); |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* Demote any published flow whose title matches a default seed name back |
| 67 |
* to draft, so the legacy Settings emails remain the sole sender. |
| 68 |
*/ |
| 69 |
private static function draft_published_seeds() { |
| 70 |
$seed_names = wp_list_pluck( self::get_default_flows(), 'name' ); |
| 71 |
|
| 72 |
$posts = get_posts( array( |
| 73 |
'post_type' => 'tt-flow', |
| 74 |
'posts_per_page' => -1, |
| 75 |
'post_status' => 'publish', |
| 76 |
'fields' => 'ids', |
| 77 |
) ); |
| 78 |
|
| 79 |
foreach ( $posts as $post_id ) { |
| 80 |
if ( in_array( get_the_title( $post_id ), $seed_names, true ) ) { |
| 81 |
wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) ); |
| 82 |
} |
| 83 |
} |
| 84 |
} |
| 85 |
|
| 86 |
/** |
| 87 |
* Delete any tt-flow posts whose nodes are missing the 'type' field — |
| 88 |
* those were created by the v1.0 seeder with the wrong format. |
| 89 |
* User-created flows always have 'type' on every node (set by the UI). |
| 90 |
* Also resets the seeded flag so maybe_seed() re-creates correct flows. |
| 91 |
*/ |
| 92 |
private static function migrate_bad_flows() { |
| 93 |
if ( get_option( self::SEEDED_OPTION ) === self::SEEDED_VERSION ) { |
| 94 |
return; |
| 95 |
} |
| 96 |
|
| 97 |
$posts = get_posts( array( |
| 98 |
'post_type' => 'tt-flow', |
| 99 |
'posts_per_page' => -1, |
| 100 |
'post_status' => 'any', |
| 101 |
'fields' => 'ids', |
| 102 |
) ); |
| 103 |
|
| 104 |
if ( empty( $posts ) ) { |
| 105 |
return; |
| 106 |
} |
| 107 |
|
| 108 |
$meta_key = '_tt_notification_flow_flow_config'; |
| 109 |
|
| 110 |
foreach ( $posts as $post_id ) { |
| 111 |
$config = get_post_meta( $post_id, $meta_key, true ); |
| 112 |
|
| 113 |
if ( empty( $config['nodes'] ) || ! is_array( $config['nodes'] ) ) { |
| 114 |
continue; |
| 115 |
} |
| 116 |
|
| 117 |
foreach ( $config['nodes'] as $node ) { |
| 118 |
if ( ! isset( $node['type'] ) ) { |
| 119 |
wp_delete_post( $post_id, true ); |
| 120 |
break; |
| 121 |
} |
| 122 |
} |
| 123 |
} |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Force re-seed (e.g., for testing). Clears flag then re-runs. |
| 128 |
*/ |
| 129 |
public static function reseed() { |
| 130 |
delete_option( self::SEEDED_OPTION ); |
| 131 |
self::maybe_seed(); |
| 132 |
} |
| 133 |
|
| 134 |
// ------------------------------------------------------------------------- |
| 135 |
// Private helpers |
| 136 |
// ------------------------------------------------------------------------- |
| 137 |
|
| 138 |
private static function create_flow( array $def ) { |
| 139 |
$flow = new Flow( 'tt' ); |
| 140 |
$flow->set_props( array( |
| 141 |
'name' => $def['name'], |
| 142 |
'trigger' => $def['trigger'], |
| 143 |
'flow_config' => $def['flow_config'], |
| 144 |
'status' => 'draft', |
| 145 |
) ); |
| 146 |
$flow->save(); |
| 147 |
} |
| 148 |
|
| 149 |
/** Edge with correct UI format. */ |
| 150 |
private static function edge( string $from, string $to ) { |
| 151 |
return array( |
| 152 |
'id' => 'edge_' . $from . '-' . $to, |
| 153 |
'type' => 'smoothstep', |
| 154 |
'markerEnd' => array( 'type' => 'arrowclosed' ), |
| 155 |
'source' => $from, |
| 156 |
'target' => $to, |
| 157 |
'data' => array( 'animated' => false ), |
| 158 |
); |
| 159 |
} |
| 160 |
|
| 161 |
/** Trigger node. */ |
| 162 |
private static function trigger_node( string $trigger_value, string $trigger_label ) { |
| 163 |
return array( |
| 164 |
'id' => 'node_1', |
| 165 |
'type' => 'trigger', |
| 166 |
'name' => 'trigger', |
| 167 |
'position' => array( 'x' => 275, 'y' => 70 ), |
| 168 |
'data' => array( |
| 169 |
'label' => 'trigger: ' . $trigger_value, |
| 170 |
'subtitle' => 'On "' . $trigger_label . '" event fires', |
| 171 |
'triggerValue' => $trigger_value, |
| 172 |
), |
| 173 |
); |
| 174 |
} |
| 175 |
|
| 176 |
/** End node. */ |
| 177 |
private static function end_node( float $y = 600.0 ) { |
| 178 |
return array( |
| 179 |
'id' => 'end_1', |
| 180 |
'type' => 'end', |
| 181 |
'name' => 'end', |
| 182 |
'position' => array( 'x' => 275, 'y' => $y ), |
| 183 |
'data' => array( |
| 184 |
'label' => 'end_flow', |
| 185 |
'subtitle' => 'Automation stops here', |
| 186 |
), |
| 187 |
); |
| 188 |
} |
| 189 |
|
| 190 |
/** Email action node. */ |
| 191 |
private static function email_node( string $node_id, string $receiver_type, string $receiver_label, string $subject, string $body, float $y = 420.0 ) { |
| 192 |
return array( |
| 193 |
'id' => $node_id, |
| 194 |
'type' => 'action', |
| 195 |
'name' => 'email', |
| 196 |
'position' => array( 'x' => 275, 'y' => $y ), |
| 197 |
'data' => array( |
| 198 |
'actionType' => 'send_email', |
| 199 |
'label' => 'send_email', |
| 200 |
'subtitle' => 'To: ' . $receiver_label, |
| 201 |
'receiverType' => $receiver_type, |
| 202 |
'from' => '', |
| 203 |
'subject' => $subject, |
| 204 |
'body' => $body, |
| 205 |
), |
| 206 |
); |
| 207 |
} |
| 208 |
|
| 209 |
/** Delay action node. */ |
| 210 |
private static function delay_node( string $node_id, int $delay, string $delay_unit, string $delay_condition, float $y = 245.0 ) { |
| 211 |
$subtitle = 'Wait for ' . $delay . ' ' . $delay_unit; |
| 212 |
if ( $delay_condition ) { |
| 213 |
$subtitle .= ' before ' . str_replace( '_', ' ', str_replace( 'before_', '', $delay_condition ) ); |
| 214 |
} |
| 215 |
|
| 216 |
return array( |
| 217 |
'id' => $node_id, |
| 218 |
'type' => 'action', |
| 219 |
'name' => 'delay', |
| 220 |
'position' => array( 'x' => 275, 'y' => $y ), |
| 221 |
'data' => array( |
| 222 |
'actionType' => 'add_delay', |
| 223 |
'label' => 'add_delay', |
| 224 |
'subtitle' => $subtitle, |
| 225 |
'delay' => $delay, |
| 226 |
'delayUnit' => $delay_unit, |
| 227 |
'delayCondition' => $delay_condition, |
| 228 |
), |
| 229 |
); |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* trigger → email → end |
| 234 |
*/ |
| 235 |
private static function simple_flow( string $trigger_value, string $trigger_label, string $receiver_type, string $receiver_label, string $subject, string $body ) { |
| 236 |
return array( |
| 237 |
'nodes' => array( |
| 238 |
self::trigger_node( $trigger_value, $trigger_label ), |
| 239 |
self::email_node( 'node_2', $receiver_type, $receiver_label, $subject, $body, 280.0 ), |
| 240 |
self::end_node( 490.0 ), |
| 241 |
), |
| 242 |
'edges' => array( |
| 243 |
self::edge( 'node_1', 'node_2' ), |
| 244 |
self::edge( 'node_2', 'end_1' ), |
| 245 |
), |
| 246 |
); |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* trigger → delay → email → end |
| 251 |
*/ |
| 252 |
private static function reminder_flow( string $trigger_value, string $trigger_label, string $receiver_type, string $receiver_label, string $subject, string $body ) { |
| 253 |
return array( |
| 254 |
'nodes' => array( |
| 255 |
self::trigger_node( $trigger_value, $trigger_label ), |
| 256 |
self::delay_node( 'node_2', 24, 'hours', 'before_meeting_date', 245.0 ), |
| 257 |
self::email_node( 'node_3', $receiver_type, $receiver_label, $subject, $body, 420.0 ), |
| 258 |
self::end_node( 600.0 ), |
| 259 |
), |
| 260 |
'edges' => array( |
| 261 |
self::edge( 'node_1', 'node_2' ), |
| 262 |
self::edge( 'node_2', 'node_3' ), |
| 263 |
self::edge( 'node_3', 'end_1' ), |
| 264 |
), |
| 265 |
); |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Definitions for all 8 default flows. |
| 270 |
*/ |
| 271 |
private static function get_default_flows() { |
| 272 |
$booking_tags = implode( '', array( |
| 273 |
'<table style="border-collapse:collapse;width:100%;margin:20px 0;">', |
| 274 |
'<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;width:40%;">Meeting</td><td style="padding:8px 0;color:#556880;">{%meeting_title%}</td></tr>', |
| 275 |
'<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Date</td><td style="padding:8px 0;color:#556880;">{%meeting_date%}</td></tr>', |
| 276 |
'<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Time</td><td style="padding:8px 0;color:#556880;">{%meeting_time%}</td></tr>', |
| 277 |
'<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Duration</td><td style="padding:8px 0;color:#556880;">{%meeting_duration%}</td></tr>', |
| 278 |
'<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Location</td><td style="padding:8px 0;color:#556880;">{%meeting_location%}</td></tr>', |
| 279 |
) ); |
| 280 |
|
| 281 |
return array( |
| 282 |
|
| 283 |
// ── Booking Confirmed ────────────────────────────────────────── |
| 284 |
array( |
| 285 |
'name' => 'Booking Confirmed – Customer', |
| 286 |
'trigger' => 'booking_created', |
| 287 |
'flow_config' => self::simple_flow( |
| 288 |
'booking_created', |
| 289 |
'After Booking Confirmation', |
| 290 |
'customer_email', |
| 291 |
'Customer Email', |
| 292 |
'Booking Confirmed: {%meeting_title%}', |
| 293 |
'<p>Hi {%customer_name%},</p><p>Your booking has been confirmed.</p>' |
| 294 |
. $booking_tags |
| 295 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Host</td><td style="padding:8px 0;color:#556880;">{%host_name%}</td></tr>' |
| 296 |
. '</table><p>Thank you!</p>' |
| 297 |
), |
| 298 |
), |
| 299 |
|
| 300 |
array( |
| 301 |
'name' => 'Booking Confirmed – Host', |
| 302 |
'trigger' => 'booking_created', |
| 303 |
'flow_config' => self::simple_flow( |
| 304 |
'booking_created', |
| 305 |
'After Booking Confirmation', |
| 306 |
'host_email', |
| 307 |
'Host Email', |
| 308 |
'New Booking: {%meeting_title%}', |
| 309 |
'<p>Hi {%host_name%},</p><p>A new booking has been made.</p>' |
| 310 |
. $booking_tags |
| 311 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Customer</td><td style="padding:8px 0;color:#556880;">{%customer_name%} ({%customer_email%})</td></tr>' |
| 312 |
. '</table><p>Thank you!</p>' |
| 313 |
), |
| 314 |
), |
| 315 |
|
| 316 |
// ── Booking Canceled ─────────────────────────────────────────── |
| 317 |
array( |
| 318 |
'name' => 'Booking Canceled – Customer', |
| 319 |
'trigger' => 'booking_canceled', |
| 320 |
'flow_config' => self::simple_flow( |
| 321 |
'booking_canceled', |
| 322 |
'After Booking Cancellation', |
| 323 |
'customer_email', |
| 324 |
'Customer Email', |
| 325 |
'Booking Canceled: {%meeting_title%}', |
| 326 |
'<p>Hi {%customer_name%},</p><p>Your booking has been canceled.</p>' |
| 327 |
. '<table style="border-collapse:collapse;width:100%;margin:20px 0;">' |
| 328 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;width:40%;">Meeting</td><td style="padding:8px 0;color:#556880;">{%meeting_title%}</td></tr>' |
| 329 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Date</td><td style="padding:8px 0;color:#556880;">{%meeting_date%}</td></tr>' |
| 330 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Time</td><td style="padding:8px 0;color:#556880;">{%meeting_time%}</td></tr>' |
| 331 |
. '</table><p>If you have questions, please contact us.</p><p>Thank you!</p>' |
| 332 |
), |
| 333 |
), |
| 334 |
|
| 335 |
array( |
| 336 |
'name' => 'Booking Canceled – Host', |
| 337 |
'trigger' => 'booking_canceled', |
| 338 |
'flow_config' => self::simple_flow( |
| 339 |
'booking_canceled', |
| 340 |
'After Booking Cancellation', |
| 341 |
'host_email', |
| 342 |
'Host Email', |
| 343 |
'Booking Canceled: {%meeting_title%}', |
| 344 |
'<p>Hi {%host_name%},</p><p>A booking has been canceled.</p>' |
| 345 |
. '<table style="border-collapse:collapse;width:100%;margin:20px 0;">' |
| 346 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;width:40%;">Meeting</td><td style="padding:8px 0;color:#556880;">{%meeting_title%}</td></tr>' |
| 347 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Date</td><td style="padding:8px 0;color:#556880;">{%meeting_date%}</td></tr>' |
| 348 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Time</td><td style="padding:8px 0;color:#556880;">{%meeting_time%}</td></tr>' |
| 349 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Customer</td><td style="padding:8px 0;color:#556880;">{%customer_name%} ({%customer_email%})</td></tr>' |
| 350 |
. '</table><p>Thank you!</p>' |
| 351 |
), |
| 352 |
), |
| 353 |
|
| 354 |
// ── Booking Rescheduled ──────────────────────────────────────── |
| 355 |
array( |
| 356 |
'name' => 'Booking Rescheduled – Customer', |
| 357 |
'trigger' => 'booking_rescheduled', |
| 358 |
'flow_config' => self::simple_flow( |
| 359 |
'booking_rescheduled', |
| 360 |
'After Booking Rescheduled', |
| 361 |
'customer_email', |
| 362 |
'Customer Email', |
| 363 |
'Booking Rescheduled: {%meeting_title%}', |
| 364 |
'<p>Hi {%customer_name%},</p><p>Your booking has been rescheduled.</p>' |
| 365 |
. '<table style="border-collapse:collapse;width:100%;margin:20px 0;">' |
| 366 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;width:40%;">Meeting</td><td style="padding:8px 0;color:#556880;">{%meeting_title%}</td></tr>' |
| 367 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">New Date</td><td style="padding:8px 0;color:#556880;">{%meeting_date%}</td></tr>' |
| 368 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">New Time</td><td style="padding:8px 0;color:#556880;">{%meeting_time%}</td></tr>' |
| 369 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Location</td><td style="padding:8px 0;color:#556880;">{%meeting_location%}</td></tr>' |
| 370 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Host</td><td style="padding:8px 0;color:#556880;">{%host_name%}</td></tr>' |
| 371 |
. '</table><p>Thank you!</p>' |
| 372 |
), |
| 373 |
), |
| 374 |
|
| 375 |
array( |
| 376 |
'name' => 'Booking Rescheduled – Host', |
| 377 |
'trigger' => 'booking_rescheduled', |
| 378 |
'flow_config' => self::simple_flow( |
| 379 |
'booking_rescheduled', |
| 380 |
'After Booking Rescheduled', |
| 381 |
'host_email', |
| 382 |
'Host Email', |
| 383 |
'Booking Rescheduled: {%meeting_title%}', |
| 384 |
'<p>Hi {%host_name%},</p><p>A booking has been rescheduled.</p>' |
| 385 |
. '<table style="border-collapse:collapse;width:100%;margin:20px 0;">' |
| 386 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;width:40%;">Meeting</td><td style="padding:8px 0;color:#556880;">{%meeting_title%}</td></tr>' |
| 387 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">New Date</td><td style="padding:8px 0;color:#556880;">{%meeting_date%}</td></tr>' |
| 388 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">New Time</td><td style="padding:8px 0;color:#556880;">{%meeting_time%}</td></tr>' |
| 389 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Customer</td><td style="padding:8px 0;color:#556880;">{%customer_name%} ({%customer_email%})</td></tr>' |
| 390 |
. '</table><p>Thank you!</p>' |
| 391 |
), |
| 392 |
), |
| 393 |
|
| 394 |
// ── Booking Reminder (24 h before) ───────────────────────────── |
| 395 |
array( |
| 396 |
'name' => 'Booking Reminder – Customer', |
| 397 |
'trigger' => 'booking_created', |
| 398 |
'flow_config' => self::reminder_flow( |
| 399 |
'booking_created', |
| 400 |
'After Booking Confirmation', |
| 401 |
'customer_email', |
| 402 |
'Customer Email', |
| 403 |
'Reminder: {%meeting_title%} is coming up', |
| 404 |
'<p>Hi {%customer_name%},</p><p>This is a reminder that your meeting is coming up soon.</p>' |
| 405 |
. $booking_tags |
| 406 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Host</td><td style="padding:8px 0;color:#556880;">{%host_name%}</td></tr>' |
| 407 |
. '</table><p>We look forward to seeing you!</p><p>Thank you!</p>' |
| 408 |
), |
| 409 |
), |
| 410 |
|
| 411 |
array( |
| 412 |
'name' => 'Booking Reminder – Host', |
| 413 |
'trigger' => 'booking_created', |
| 414 |
'flow_config' => self::reminder_flow( |
| 415 |
'booking_created', |
| 416 |
'After Booking Confirmation', |
| 417 |
'host_email', |
| 418 |
'Host Email', |
| 419 |
'Reminder: {%meeting_title%} is coming up', |
| 420 |
'<p>Hi {%host_name%},</p><p>This is a reminder that a meeting is coming up soon.</p>' |
| 421 |
. $booking_tags |
| 422 |
. '<tr><td style="padding:8px 0;font-weight:600;color:#0C274A;">Customer</td><td style="padding:8px 0;color:#556880;">{%customer_name%} ({%customer_email%})</td></tr>' |
| 423 |
. '</table><p>Thank you!</p>' |
| 424 |
), |
| 425 |
), |
| 426 |
|
| 427 |
); |
| 428 |
} |
| 429 |
} |
| 430 |
|