PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 2.8.18 All 259 releases
wpfunnels / includes / core / MCP / Tools / StepTools.php

StepTools.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/MCP/Tools/StepTools.php

1,080 lines 36.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * StepTools — funnel step abilities.
4 *
5 * @package WPFunnels\MCP
6 * @since 3.13.0
7 */
8
9 namespace WPFunnels\MCP\Tools;
10
11 defined( 'ABSPATH' ) || exit;
12
13 use WPFunnels\MCP\Helpers\MCPHelper;
14 use WPFunnels\Metas\Wpfnl_Step_Meta_keys;
15 use WPFunnels\Rest\Controllers\StepController;
16 use WPFunnels\Wpfnl;
17 use WPFunnels\Wpfnl_functions;
18
19 /**
20 * Class StepTools
21 */
22 class StepTools {
23
24 /**
25 * Canonical step order used to validate and sort a funnel flow.
26 */
27 private const CANONICAL_ORDER = [ 'landing', 'optin', 'checkout', 'upsell', 'downsell', 'thankyou' ];
28
29 /**
30 * Step types a funnel may only contain once.
31 */
32 private const SINGLETON_TYPES = [ 'landing', 'checkout', 'thankyou' ];
33
34 /**
35 * Ability definitions for this domain.
36 *
37 * @return array
38 */
39 public static function definitions() {
40 $step_types = array_keys( MCPHelper::supportedStepTypes() );
41
42 return [
43 'wpfunnels/list-steps' => [
44 'label' => __( 'List Funnel Steps', 'wpfnl' ),
45 'description' => 'Ordered steps of a funnel with id, name, type, view and edit URLs, plus any attached products. Use this to resolve a step name to an ID before writing.',
46 'input_schema' => [
47 'type' => 'object',
48 'properties' => [
49 'funnel_id' => [
50 'type' => 'integer',
51 'description' => 'Funnel post ID.',
52 ],
53 ],
54 'required' => [ 'funnel_id' ],
55 ],
56 'execute_callback' => [ __CLASS__, 'listSteps' ],
57 'permission_callback' => MCPHelper::currentUserCan(),
58 'annotations' => [ 'readonly' ],
59 ],
60 'wpfunnels/create-step' => [
61 'label' => __( 'Create Funnel Step', 'wpfnl' ),
62 'description' => 'Add a step to a funnel and append it to the flow. The page starts blank — content is written separately. A funnel may hold only one landing, one checkout and one thank-you step, and offer steps (upsell/downsell) require WooCommerce.',
63 'input_schema' => [
64 'type' => 'object',
65 'properties' => [
66 'funnel_id' => [
67 'type' => 'integer',
68 'description' => 'Funnel post ID.',
69 ],
70 'step_type' => [
71 'type' => 'string',
72 'description' => 'Step type. Call get-funnel-context for what this install supports.',
73 'enum' => $step_types,
74 ],
75 'name' => [
76 'type' => 'string',
77 'description' => 'Step name. Defaults to a title derived from the type.',
78 ],
79 ],
80 'required' => [ 'funnel_id', 'step_type' ],
81 ],
82 'execute_callback' => [ __CLASS__, 'createStep' ],
83 'permission_callback' => MCPHelper::currentUserCan(),
84 'annotations' => [],
85 ],
86 'wpfunnels/update-step' => [
87 'label' => __( 'Update Funnel Step', 'wpfnl' ),
88 'description' => 'Rename a step or change its published status. The step name also drives its URL slug, so renaming a live step changes the link visitors use.',
89 'input_schema' => [
90 'type' => 'object',
91 'properties' => [
92 'step_id' => [
93 'type' => 'integer',
94 'description' => 'Step post ID.',
95 ],
96 'name' => [
97 'type' => 'string',
98 'description' => 'New step name.',
99 ],
100 'status' => [
101 'type' => 'string',
102 'description' => 'New post status.',
103 'enum' => [ 'publish', 'draft' ],
104 ],
105 ],
106 'required' => [ 'step_id' ],
107 ],
108 'execute_callback' => [ __CLASS__, 'updateStep' ],
109 'permission_callback' => MCPHelper::currentUserCan(),
110 'annotations' => [ 'destructive' ],
111 ],
112 'wpfunnels/reorder-steps' => [
113 'label' => __( 'Reorder Funnel Steps', 'wpfnl' ),
114 'description' => 'Set the complete step order of a funnel. This REPLACES the whole order, so pass every step ID — any omitted step is dropped from the flow. Read list-steps first, then send the full array.',
115 'input_schema' => [
116 'type' => 'object',
117 'properties' => [
118 'funnel_id' => [
119 'type' => 'integer',
120 'description' => 'Funnel post ID.',
121 ],
122 'step_ids' => [
123 'type' => 'array',
124 'description' => 'Every step ID of the funnel, in the order visitors should move through them.',
125 'items' => [ 'type' => 'integer' ],
126 ],
127 ],
128 'required' => [ 'funnel_id', 'step_ids' ],
129 ],
130 'execute_callback' => [ __CLASS__, 'reorderSteps' ],
131 'permission_callback' => MCPHelper::currentUserCan(),
132 'annotations' => [ 'destructive' ],
133 ],
134 'wpfunnels/get-step' => [
135 'label' => __( 'Get Step', 'wpfnl' ),
136 'description' => 'Detail for one step: name, type, status, funnel, URLs and attached products (if any). Does not return page-builder content — use get-step-outline for that.',
137 'input_schema' => [
138 'type' => 'object',
139 'properties' => [
140 'step_id' => [
141 'type' => 'integer',
142 'description' => 'Step post ID.',
143 ],
144 ],
145 'required' => [ 'step_id' ],
146 ],
147 'execute_callback' => [ __CLASS__, 'getStep' ],
148 'permission_callback' => MCPHelper::currentUserCan(),
149 'annotations' => [ 'readonly' ],
150 ],
151 'wpfunnels/delete-step' => [
152 'label' => __( 'Delete Step', 'wpfnl' ),
153 'description' => 'Permanently remove a step from its funnel, including its page content. This cannot be undone.',
154 'input_schema' => [
155 'type' => 'object',
156 'properties' => [
157 'step_id' => [
158 'type' => 'integer',
159 'description' => 'Step post ID.',
160 ],
161 ],
162 'required' => [ 'step_id' ],
163 ],
164 'execute_callback' => [ __CLASS__, 'deleteStep' ],
165 'permission_callback' => MCPHelper::currentUserCan(),
166 'annotations' => [ 'destructive' ],
167 ],
168 'wpfunnels/copy-step' => [
169 'label' => __( 'Copy Step', 'wpfnl' ),
170 'description' => 'Duplicate a step, including its page content, and append the copy to a funnel (the same funnel by default). Does not overwrite anything — the copy is a new step.',
171 'input_schema' => [
172 'type' => 'object',
173 'properties' => [
174 'step_id' => [
175 'type' => 'integer',
176 'description' => 'Step to duplicate.',
177 ],
178 'target_funnel_id' => [
179 'type' => 'integer',
180 'description' => 'Funnel to append the copy to. Defaults to the source step\'s own funnel.',
181 ],
182 ],
183 'required' => [ 'step_id' ],
184 ],
185 'execute_callback' => [ __CLASS__, 'copyStep' ],
186 'permission_callback' => MCPHelper::currentUserCan(),
187 'annotations' => [],
188 ],
189 'wpfunnels/get-step-settings' => [
190 'label' => __( 'Get Step Settings', 'wpfnl' ),
191 'description' => 'Read the WPFunnels-specific settings for a step (the fields shown in its settings tab — these are separate from page-builder content).',
192 'input_schema' => [
193 'type' => 'object',
194 'properties' => [
195 'step_id' => [
196 'type' => 'integer',
197 'description' => 'Step post ID.',
198 ],
199 ],
200 'required' => [ 'step_id' ],
201 ],
202 'execute_callback' => [ __CLASS__, 'getStepSettings' ],
203 'permission_callback' => MCPHelper::currentUserCan(),
204 'annotations' => [ 'readonly' ],
205 ],
206 'wpfunnels/update-step-settings' => [
207 'label' => __( 'Update Step Settings', 'wpfnl' ),
208 'description' => 'Write one or more WPFunnels-specific settings for a step (from get-step-settings). Only recognized keys for the step\'s type are written; unknown keys are ignored.',
209 'input_schema' => [
210 'type' => 'object',
211 'properties' => [
212 'step_id' => [
213 'type' => 'integer',
214 'description' => 'Step post ID.',
215 ],
216 'settings' => [
217 'type' => 'object',
218 'description' => 'Key/value pairs matching the keys returned by get-step-settings.',
219 ],
220 ],
221 'required' => [ 'step_id', 'settings' ],
222 ],
223 'execute_callback' => [ __CLASS__, 'updateStepSettings' ],
224 'permission_callback' => MCPHelper::currentUserCan(),
225 'annotations' => [ 'destructive' ],
226 ],
227 'wpfunnels/get-step-capabilities' => [
228 'label' => __( 'Get Step Capabilities', 'wpfnl' ),
229 'description' => 'What step types, and which of them can hold products, this install currently supports — check this before proposing a step type.',
230 'input_schema' => [
231 'type' => 'object',
232 'properties' => [],
233 ],
234 'execute_callback' => [ __CLASS__, 'getStepCapabilities' ],
235 'permission_callback' => MCPHelper::currentUserCan(),
236 'annotations' => [ 'readonly' ],
237 ],
238 'wpfunnels/get-step-conditions' => [
239 'label' => __( 'Get Step Conditions', 'wpfnl' ),
240 'description' => 'Read a step\'s conditional-branching setup: whether it is enabled, the condition rules that decide the true/false outcome, and which next step each outcome routes to. This is the canvas\'s "conditional rules" feature — separate from ordinary linear step order.',
241 'input_schema' => [
242 'type' => 'object',
243 'properties' => [
244 'step_id' => [
245 'type' => 'integer',
246 'description' => 'Step post ID.',
247 ],
248 ],
249 'required' => [ 'step_id' ],
250 ],
251 'execute_callback' => [ __CLASS__, 'getStepConditions' ],
252 'permission_callback' => MCPHelper::currentUserCan(),
253 'annotations' => [ 'readonly' ],
254 ],
255 'wpfunnels/upsert-step-conditions' => [
256 'label' => __( 'Upsert Step Conditions', 'wpfnl' ),
257 'description' => 'Write a step\'s conditional-branching rules and enable/disable branching in one call. Read wpfunnels/get-step-conditions first to see the current shape (and, if you only mean to flip enabled on/off, to avoid overwriting existing rules/routing with empty ones). Enabling a step with two outgoing canvas connections is what makes conditional branching apply.',
258 'input_schema' => [
259 'type' => 'object',
260 'properties' => [
261 'step_id' => [
262 'type' => 'integer',
263 'description' => 'Step post ID.',
264 ],
265 'conditions' => [
266 'type' => 'array',
267 'description' => 'OR-groups of AND condition rows. Each item is an array of condition objects shaped like {field, condition, value, selectedCondition}, e.g. [[{"field":"optin_123","condition":"is","value":"yes","selectedCondition":""}]]. Matches the shape returned by get-step-conditions.',
268 'items' => [ 'type' => 'array' ],
269 ],
270 'after_condition' => [
271 'type' => 'object',
272 'description' => 'Next-step routing keyed by the condition outcome, e.g. {"true": 456, "false": 789}. Omit to keep the step\'s existing routing unchanged.',
273 ],
274 'enabled' => [
275 'type' => 'boolean',
276 'description' => 'Whether conditional branching is active for this step. Defaults to true when conditions is non-empty, false when conditions is empty.',
277 ],
278 ],
279 'required' => [ 'step_id', 'conditions' ],
280 ],
281 'execute_callback' => [ __CLASS__, 'upsertStepConditions' ],
282 'permission_callback' => MCPHelper::currentUserCan(),
283 'annotations' => [ 'destructive' ],
284 ],
285 ];
286 }
287
288 /**
289 * List the steps of a funnel.
290 *
291 * @param array $input Tool input.
292 * @return array|\WP_Error
293 */
294 public static function listSteps( $input = [] ) {
295 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
296 if ( is_wp_error( $funnel ) ) {
297 return $funnel;
298 }
299
300 $funnel_id = (int) $funnel->ID;
301 $steps = Wpfnl_functions::get_steps( $funnel_id );
302 $steps = is_array( $steps ) ? $steps : [];
303
304 $items = [];
305 foreach ( $steps as $index => $step ) {
306 $summary = MCPHelper::formatStepSummary( $step );
307 $summary['order'] = $index + 1;
308
309 if ( MCPHelper::stepTypeHoldsProducts( $summary['step_type'] ) ) {
310 $summary['products'] = ProductTools::attachedProducts( $summary['id'], $summary['step_type'] );
311 }
312
313 $items[] = $summary;
314 }
315
316 return [
317 'funnel_id' => $funnel_id,
318 'funnel_name' => $funnel->post_title,
319 'steps' => $items,
320 'step_count' => count( $items ),
321 ];
322 }
323
324 /**
325 * Create a step and append it to the funnel flow.
326 *
327 * @param array $input Tool input.
328 * @return array|\WP_Error
329 */
330 public static function createStep( $input = [] ) {
331 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
332 if ( is_wp_error( $funnel ) ) {
333 return $funnel;
334 }
335
336 $funnel_id = (int) $funnel->ID;
337 $step_type = isset( $input['step_type'] ) ? sanitize_text_field( $input['step_type'] ) : '';
338 $supported = MCPHelper::supportedStepTypes();
339
340 if ( ! isset( $supported[ $step_type ] ) ) {
341 return MCPHelper::error(
342 'unsupported_step_type',
343 sprintf(
344 'Step type "%s" is not available on this site. Supported types: %s.',
345 $step_type,
346 implode( ', ', array_keys( $supported ) )
347 ),
348 [ 'supported_step_types' => array_keys( $supported ) ]
349 );
350 }
351
352 $existing = Wpfnl_functions::get_steps( $funnel_id );
353 $existing = is_array( $existing ) ? $existing : [];
354
355 if ( in_array( $step_type, self::SINGLETON_TYPES, true ) ) {
356 foreach ( $existing as $step ) {
357 if ( isset( $step['step_type'] ) && $step_type === $step['step_type'] ) {
358 return MCPHelper::error(
359 'duplicate_step_type',
360 sprintf(
361 'This funnel already has a %s step (ID %d). A funnel may only contain one.',
362 $step_type,
363 isset( $step['id'] ) ? (int) $step['id'] : 0
364 ),
365 [ 'existing_step_id' => isset( $step['id'] ) ? (int) $step['id'] : 0 ]
366 );
367 }
368 }
369 }
370
371 $name = isset( $input['name'] ) && '' !== trim( (string) $input['name'] )
372 ? sanitize_text_field( $input['name'] )
373 : ucfirst( $step_type );
374
375 $step_store = Wpfnl::get_instance()->step_store;
376 $step_id = $step_store->create_step( $funnel_id, $name, $step_type );
377
378 if ( is_wp_error( $step_id ) ) {
379 return $step_id;
380 }
381 if ( ! $step_id ) {
382 return MCPHelper::error( 'create_step_failed', 'WordPress could not create the step post.' );
383 }
384
385 $funnel_store = Wpfnl::get_instance()->funnel_store;
386 $funnel_store->set_id( $funnel_id );
387 // Hydrate the store's in-memory step list from the DB BEFORE appending.
388 // Funnel_Store::save_steps_order() only ever appends to
389 // $this->steps_order and then persists that array as the funnel's
390 // COMPLETE _steps_order — set_id() alone never loads the existing
391 // steps, so without this call every create-step request (each tool
392 // call here is its own separate PHP request/AgentLoop::step()) starts
393 // from an empty list and overwrites _steps_order with just the one
394 // new step, silently discarding every step a prior call had added.
395 // That's why a 4-step AI build left the funnel canvas with none of
396 // them wired in.
397 $funnel_store->set_steps_order();
398 $funnel_store->save_steps_order( $step_id, $step_type, $name );
399
400 // _steps_order is correct now, but the VISUAL canvas doesn't read it —
401 // it renders _funnel_data's drawflow node graph, which nothing here
402 // (or anywhere server-side) ever builds for an AI-created funnel. See
403 // self::syncFunnelCanvasData().
404 self::syncFunnelCanvasData( $funnel_id );
405
406 ContextTools::invalidateCache();
407
408 return [
409 'success' => true,
410 'step_id' => (int) $step_id,
411 'funnel_id' => $funnel_id,
412 'step_type' => $step_type,
413 'name' => $name,
414 'view_url' => get_permalink( $step_id ),
415 'edit_url' => get_edit_post_link( $step_id, 'raw' ),
416 'next_step' => MCPHelper::stepTypeHoldsProducts( $step_type )
417 ? 'Attach products with wpfunnels/assign-products-to-step.'
418 : 'The page is blank; add content next.',
419 'flow_notice' => self::flowNotice( $funnel_id ),
420 ];
421 }
422
423 /**
424 * Update a step's name or status.
425 *
426 * @param array $input Tool input.
427 * @return array|\WP_Error
428 */
429 public static function updateStep( $input = [] ) {
430 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
431 if ( is_wp_error( $step ) ) {
432 return $step;
433 }
434
435 $step_id = (int) $step->ID;
436 $funnel_id = (int) get_post_meta( $step_id, '_funnel_id', true );
437 $changed = [];
438 $post_args = [ 'ID' => $step_id ];
439
440 if ( isset( $input['name'] ) && '' !== trim( (string) $input['name'] ) ) {
441 $post_args['post_title'] = sanitize_text_field( $input['name'] );
442 $changed[] = 'name';
443 }
444
445 if ( isset( $input['status'] ) && in_array( $input['status'], [ 'publish', 'draft' ], true ) ) {
446 $post_args['post_status'] = $input['status'];
447 $changed[] = 'status';
448 }
449
450 if ( empty( $changed ) ) {
451 return MCPHelper::error( 'nothing_to_update', 'Provide at least one of name or status.' );
452 }
453
454 $updated = wp_update_post( $post_args, true );
455 if ( is_wp_error( $updated ) ) {
456 return $updated;
457 }
458
459 // The funnel keeps its own copy of step names in _steps_order.
460 if ( $funnel_id && in_array( 'name', $changed, true ) ) {
461 self::syncStepNameInOrder( $funnel_id, $step_id, $post_args['post_title'] );
462 }
463
464 ContextTools::invalidateCache();
465
466 return [
467 'success' => true,
468 'step_id' => $step_id,
469 'funnel_id' => $funnel_id,
470 'name' => get_the_title( $step_id ),
471 'status' => get_post_status( $step_id ),
472 'view_url' => get_permalink( $step_id ),
473 'changed' => $changed,
474 ];
475 }
476
477 /**
478 * Replace the funnel's step order.
479 *
480 * @param array $input Tool input.
481 * @return array|\WP_Error
482 */
483 public static function reorderSteps( $input = [] ) {
484 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
485 if ( is_wp_error( $funnel ) ) {
486 return $funnel;
487 }
488
489 $funnel_id = (int) $funnel->ID;
490 $step_ids = isset( $input['step_ids'] ) && is_array( $input['step_ids'] ) ? array_map( 'intval', $input['step_ids'] ) : [];
491
492 if ( empty( $step_ids ) ) {
493 return MCPHelper::error( 'missing_step_ids', 'Pass every step ID of the funnel in the desired order.' );
494 }
495
496 $current = Wpfnl_functions::get_steps( $funnel_id );
497 $current = is_array( $current ) ? $current : [];
498
499 $by_id = [];
500 foreach ( $current as $step ) {
501 if ( isset( $step['id'] ) ) {
502 $by_id[ (int) $step['id'] ] = $step;
503 }
504 }
505
506 // Every id must belong to this funnel — a stray id means the model mixed
507 // up funnels, and silently dropping it would corrupt the flow.
508 $unknown = array_values( array_diff( $step_ids, array_keys( $by_id ) ) );
509 if ( ! empty( $unknown ) ) {
510 return MCPHelper::error(
511 'step_not_in_funnel',
512 sprintf( 'These step IDs do not belong to funnel %d: %s.', $funnel_id, implode( ', ', $unknown ) ),
513 [ 'unknown_step_ids' => $unknown ]
514 );
515 }
516
517 $duplicates = array_keys( array_filter( array_count_values( $step_ids ), static function ( $count ) {
518 return $count > 1;
519 } ) );
520 if ( ! empty( $duplicates ) ) {
521 return MCPHelper::error(
522 'duplicate_step_ids',
523 sprintf( 'Step IDs repeated in the order: %s.', implode( ', ', $duplicates ) )
524 );
525 }
526
527 $dropped = array_values( array_diff( array_keys( $by_id ), $step_ids ) );
528
529 $reordered = [];
530 foreach ( $step_ids as $step_id ) {
531 $reordered[] = $by_id[ $step_id ];
532 }
533
534 update_post_meta( $funnel_id, '_steps_order', $reordered );
535
536 $funnel_store = Wpfnl::get_instance()->funnel_store;
537 $funnel_store->set_id( $funnel_id );
538 $funnel_store->set_steps_order();
539
540 // Keep the visual canvas's node graph in the same order.
541 self::syncFunnelCanvasData( $funnel_id );
542
543 ContextTools::invalidateCache();
544
545 return [
546 'success' => true,
547 'funnel_id' => $funnel_id,
548 'order' => array_map(
549 static function ( $step ) {
550 return MCPHelper::formatStepSummary( $step );
551 },
552 $reordered
553 ),
554 'dropped_step_ids' => $dropped,
555 'flow_notice' => self::flowNotice( $funnel_id ),
556 ];
557 }
558
559 /**
560 * Get one step.
561 *
562 * @param array $input Tool input.
563 * @return array|\WP_Error
564 */
565 public static function getStep( $input = [] ) {
566 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
567 if ( is_wp_error( $step ) ) {
568 return $step;
569 }
570
571 $step_id = (int) $step->ID;
572 $step_type = (string) get_post_meta( $step_id, '_step_type', true );
573 $funnel_id = (int) get_post_meta( $step_id, '_funnel_id', true );
574
575 $data = [
576 'id' => $step_id,
577 'name' => $step->post_title,
578 'step_type' => $step_type,
579 'status' => $step->post_status,
580 'funnel_id' => $funnel_id,
581 'view_url' => get_permalink( $step_id ),
582 'edit_url' => get_edit_post_link( $step_id, 'raw' ),
583 'created_at' => $step->post_date,
584 'modified_at' => $step->post_modified,
585 ];
586
587 if ( MCPHelper::stepTypeHoldsProducts( $step_type ) ) {
588 $data['products'] = ProductTools::attachedProducts( $step_id, $step_type );
589 }
590
591 return $data;
592 }
593
594 /**
595 * Permanently delete a step, reusing the same controller logic the
596 * builder canvas' delete action calls.
597 *
598 * @param array $input Tool input.
599 * @return array|\WP_Error
600 */
601 public static function deleteStep( $input = [] ) {
602 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
603 if ( is_wp_error( $step ) ) {
604 return $step;
605 }
606
607 $step_id = (int) $step->ID;
608 $funnel_id = (int) get_post_meta( $step_id, '_funnel_id', true );
609
610 if ( ! class_exists( '\WPFunnels\Rest\Controllers\StepController' ) ) {
611 return MCPHelper::error( 'controller_unavailable', 'The step controller is not available.' );
612 }
613
614 $controller = new StepController();
615 $response = $controller->delete_step( [ 'step_id' => $step_id ] );
616
617 if ( is_wp_error( $response ) ) {
618 return $response;
619 }
620
621 // The controller only wp_delete_post()s the step — it never prunes the
622 // funnel's own _steps_order copy or the drawflow canvas graph built from
623 // it, so a deleted step keeps showing up as a nameless, linkless ghost
624 // node on the canvas until both are refreshed here too.
625 if ( $funnel_id ) {
626 $remaining = array_values(
627 array_filter(
628 Wpfnl_functions::get_steps( $funnel_id ),
629 static function ( $step_row ) use ( $step_id ) {
630 return isset( $step_row['id'] ) && (int) $step_row['id'] !== $step_id;
631 }
632 )
633 );
634 update_post_meta( $funnel_id, '_steps_order', $remaining );
635
636 $funnel_store = Wpfnl::get_instance()->funnel_store;
637 $funnel_store->set_id( $funnel_id );
638 $funnel_store->set_steps_order();
639
640 self::syncFunnelCanvasData( $funnel_id );
641 }
642
643 ContextTools::invalidateCache();
644
645 return [
646 'success' => true,
647 'step_id' => $step_id,
648 'funnel_id' => $funnel_id,
649 ];
650 }
651
652 /**
653 * Duplicate a step, reusing the same clone logic the builder canvas'
654 * copy/paste action calls.
655 *
656 * @param array $input Tool input.
657 * @return array|\WP_Error
658 */
659 public static function copyStep( $input = [] ) {
660 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
661 if ( is_wp_error( $step ) ) {
662 return $step;
663 }
664
665 $step_id = (int) $step->ID;
666 $source_funnel_id = (int) get_post_meta( $step_id, '_funnel_id', true );
667 $target_funnel_id = isset( $input['target_funnel_id'] ) ? (int) $input['target_funnel_id'] : $source_funnel_id;
668
669 $target = MCPHelper::requireFunnel( $target_funnel_id );
670 if ( is_wp_error( $target ) ) {
671 return $target;
672 }
673
674 if ( ! class_exists( '\WPFunnels\Rest\Controllers\StepController' ) ) {
675 return MCPHelper::error( 'controller_unavailable', 'The step controller is not available.' );
676 }
677
678 $controller = new StepController();
679 $response = $controller->paste_step(
680 [
681 'stepId' => $step_id,
682 'funnelId' => $target_funnel_id,
683 ]
684 );
685
686 if ( is_wp_error( $response ) ) {
687 return $response;
688 }
689
690 ContextTools::invalidateCache();
691
692 $data = is_array( $response ) ? $response : ( method_exists( $response, 'get_data' ) ? $response->get_data() : [] );
693
694 return [
695 'success' => true,
696 'funnel_id' => $target_funnel_id,
697 'result' => $data,
698 ];
699 }
700
701 /**
702 * Read a step's WPFunnels-specific settings (allow-listed by step type).
703 *
704 * @param array $input Tool input.
705 * @return array|\WP_Error
706 */
707 public static function getStepSettings( $input = [] ) {
708 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
709 if ( is_wp_error( $step ) ) {
710 return $step;
711 }
712
713 $step_id = (int) $step->ID;
714 $step_type = (string) get_post_meta( $step_id, '_step_type', true );
715 $default_meta = Wpfnl_functions::get_step_default_meta( $step_type );
716
717 $settings = [];
718 foreach ( (array) $default_meta as $key => $meta ) {
719 $settings[ $key ] = get_post_meta( $step_id, $key, true );
720 }
721
722 return [
723 'step_id' => $step_id,
724 'step_type' => $step_type,
725 'settings' => $settings,
726 ];
727 }
728
729 /**
730 * Write a step's WPFunnels-specific settings.
731 *
732 * Reuses `Wpfnl_Step_Meta_keys::save_meta()` — the same allow-list logic
733 * the step settings panel writes through, so a stray key here is silently
734 * ignored rather than creating an unrecognized meta entry.
735 *
736 * @param array $input Tool input.
737 * @return array|\WP_Error
738 */
739 public static function updateStepSettings( $input = [] ) {
740 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
741 if ( is_wp_error( $step ) ) {
742 return $step;
743 }
744
745 $settings = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : [];
746 if ( empty( $settings ) ) {
747 return MCPHelper::error( 'missing_settings', 'Provide at least one setting to write.' );
748 }
749
750 $step_id = (int) $step->ID;
751 $step_type = (string) get_post_meta( $step_id, '_step_type', true );
752 $default_meta = Wpfnl_functions::get_step_default_meta( $step_type );
753
754 if ( empty( $default_meta ) ) {
755 return MCPHelper::error( 'no_settings_for_type', sprintf( 'Step type "%s" has no recognized settings.', $step_type ) );
756 }
757
758 Wpfnl_Step_Meta_keys::save_meta( $step_id, $settings, $default_meta );
759
760 ContextTools::invalidateCache();
761
762 return self::getStepSettings( [ 'step_id' => $step_id ] );
763 }
764
765 /**
766 * What step types (and product-holding types) this install supports.
767 *
768 * @param array $input Tool input.
769 * @return array
770 */
771 public static function getStepCapabilities( $input = [] ) {
772 $types = MCPHelper::supportedStepTypes();
773 $product_holding = array_values( array_filter( array_keys( $types ), [ MCPHelper::class, 'stepTypeHoldsProducts' ] ) );
774
775 return [
776 'step_types' => $types,
777 'product_holding_types' => $product_holding,
778 'active_builder' => method_exists( '\WPFunnels\Wpfnl_functions', 'get_builder_type' )
779 ? Wpfnl_functions::get_builder_type()
780 : '',
781 ];
782 }
783
784 /**
785 * Read a step's conditional-branching setup, reusing the same controller
786 * logic the builder canvas' condition drawer calls.
787 *
788 * @param array $input Tool input.
789 * @return array|\WP_Error
790 */
791 public static function getStepConditions( $input = [] ) {
792 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
793 if ( is_wp_error( $step ) ) {
794 return $step;
795 }
796
797 $step_id = (int) $step->ID;
798
799 if ( ! class_exists( '\WPFunnels\Rest\Controllers\StepController' ) ) {
800 return MCPHelper::error( 'controller_unavailable', 'The step controller is not available.' );
801 }
802
803 $controller = new StepController();
804 $response = $controller->get_conditions( [ 'stepId' => $step_id ] );
805
806 if ( is_wp_error( $response ) ) {
807 return $response;
808 }
809
810 $data = is_array( $response ) ? $response : ( method_exists( $response, 'get_data' ) ? $response->get_data() : [] );
811
812 return [
813 'step_id' => $step_id,
814 'enabled' => isset( $data['status'] ) && 'yes' === $data['status'],
815 'conditions' => isset( $data['conditions'] ) ? $data['conditions'] : [],
816 'after_condition' => isset( $data['afterCondition'] ) ? $data['afterCondition'] : [],
817 ];
818 }
819
820 /**
821 * Write a step's conditional-branching rules and sync its enabled flag,
822 * reusing the same controller logic the builder canvas' condition drawer
823 * calls.
824 *
825 * The drawer itself makes two separate REST calls in sequence — flip
826 * `_wpfnl_maybe_enable_condition` via update_conditional_status(), then
827 * (only when switching branching ON) write the rules via
828 * save_condition() — because it has two separate widgets (an on/off
829 * switch and a save button) that can be operated independently. A single
830 * MCP call has no such separation: the model always hands us the full
831 * desired state in one shot, so to keep the enabled flag and the stored
832 * conditions from ever drifting out of sync we always call both, in the
833 * same order the drawer does.
834 *
835 * @param array $input Tool input.
836 * @return array|\WP_Error
837 */
838 public static function upsertStepConditions( $input = [] ) {
839 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
840 if ( is_wp_error( $step ) ) {
841 return $step;
842 }
843
844 if ( ! isset( $input['conditions'] ) || ! is_array( $input['conditions'] ) ) {
845 return MCPHelper::error( 'missing_conditions', 'Provide the conditions array — see wpfunnels/get-step-conditions for the expected shape.' );
846 }
847
848 $step_id = (int) $step->ID;
849 $conditions = $input['conditions'];
850 $enabled = isset( $input['enabled'] ) ? (bool) $input['enabled'] : ! empty( $conditions );
851
852 if ( ! class_exists( '\WPFunnels\Rest\Controllers\StepController' ) ) {
853 return MCPHelper::error( 'controller_unavailable', 'The step controller is not available.' );
854 }
855
856 $controller = new StepController();
857
858 // Preserve the existing next-step routing when the caller doesn't pass
859 // after_condition — save_condition() always overwrites it with whatever
860 // is given (defaulting to an empty array), so silently omitting it here
861 // would wipe out routing the user already configured on the canvas.
862 if ( isset( $input['after_condition'] ) && is_array( $input['after_condition'] ) ) {
863 $after_condition = $input['after_condition'];
864 } else {
865 $existing = $controller->get_conditions( [ 'stepId' => $step_id ] );
866 $existing_data = is_wp_error( $existing )
867 ? []
868 : ( is_array( $existing ) ? $existing : ( method_exists( $existing, 'get_data' ) ? $existing->get_data() : [] ) );
869 $after_condition = isset( $existing_data['afterCondition'] ) && is_array( $existing_data['afterCondition'] )
870 ? $existing_data['afterCondition']
871 : [];
872 }
873
874 $status_response = $controller->update_conditional_status(
875 [
876 'stepId' => $step_id,
877 'status' => $enabled ? 'yes' : 'no',
878 ]
879 );
880 if ( is_wp_error( $status_response ) ) {
881 return $status_response;
882 }
883
884 $save_response = $controller->save_condition(
885 [
886 'stepId' => $step_id,
887 'conditions' => $conditions,
888 'afterCondition' => $after_condition,
889 ]
890 );
891 if ( is_wp_error( $save_response ) ) {
892 return $save_response;
893 }
894
895 ContextTools::invalidateCache();
896
897 return [
898 'success' => true,
899 'step_id' => $step_id,
900 'enabled' => $enabled,
901 'conditions' => $conditions,
902 'after_condition' => $after_condition,
903 ];
904 }
905
906 /**
907 * Warn when the stored flow deviates from the canonical order.
908 *
909 * Advisory only: unusual orders are legal, but the model should know it
910 * built something visitors may not expect.
911 *
912 * @param int $funnel_id Funnel id.
913 * @return string
914 */
915 private static function flowNotice( $funnel_id ) {
916 $steps = Wpfnl_functions::get_steps( $funnel_id );
917 $steps = is_array( $steps ) ? $steps : [];
918
919 $positions = [];
920 foreach ( $steps as $step ) {
921 $type = isset( $step['step_type'] ) ? $step['step_type'] : '';
922 $index = array_search( $type, self::CANONICAL_ORDER, true );
923 if ( false !== $index ) {
924 $positions[] = $index;
925 }
926 }
927
928 $sorted = $positions;
929 sort( $sorted );
930
931 if ( $positions !== $sorted ) {
932 return 'The current order differs from the usual flow (landing, opt-in, checkout, upsell, downsell, thank-you). Reorder with wpfunnels/reorder-steps if that was not deliberate.';
933 }
934
935 return '';
936 }
937
938 /**
939 * Keep the funnel's `_steps_order` copy of a step name in sync.
940 *
941 * @param int $funnel_id Funnel id.
942 * @param int $step_id Step id.
943 * @param string $name New name.
944 * @return void
945 */
946 private static function syncStepNameInOrder( $funnel_id, $step_id, $name ) {
947 $steps = Wpfnl_functions::get_steps( $funnel_id );
948 if ( ! is_array( $steps ) ) {
949 return;
950 }
951
952 $dirty = false;
953 foreach ( $steps as $index => $step ) {
954 if ( isset( $step['id'] ) && (int) $step['id'] === (int) $step_id ) {
955 $steps[ $index ]['name'] = $name;
956 $dirty = true;
957 }
958 }
959
960 if ( $dirty ) {
961 update_post_meta( $funnel_id, '_steps_order', $steps );
962 }
963 }
964
965 /**
966 * Rebuild the funnel's `_funnel_data` drawflow node graph from its current
967 * `_steps_order` list.
968 *
969 * The visual canvas (admin/src/components/funnel-window, "newUI") renders
970 * nodes exclusively from `_funnel_data['drawflow']['Home']['data']` — see
971 * Funnel_Controller::prepare_funnel_data_response(), which falls back to
972 * an empty "scratch-funnel" canvas whenever that's missing, REGARDLESS of
973 * how many entries `_steps_order`/`_steps` hold. Nothing server-side ever
974 * builds it for a normal "Add Step" click either: the manual flow only
975 * creates the step post over REST (StepController::create_step()) and
976 * leaves the browser's own drawflow.js state to insert the node and push
977 * the complete graph back via the save-funnel endpoint. An AI-driven
978 * build has no browser in the loop, so without this the canvas stays
979 * blank forever no matter how many steps actually exist.
980 *
981 * Rebuilds the WHOLE graph from scratch on every step/reorder call rather
982 * than patching the previous one — AI-built funnels are linear (per
983 * SystemPrompt's playbooks: Landing -> Checkout -> Upsell -> Downsell ->
984 * Thank You, no branching), so there's nothing bespoke to preserve, and a
985 * full rebuild is simpler and far less error-prone than diffing node ids
986 * and connections against whatever was there before.
987 *
988 * @param int $funnel_id Funnel id.
989 * @return void
990 */
991 private static function syncFunnelCanvasData( $funnel_id ) {
992 $steps = Wpfnl_functions::get_steps( $funnel_id );
993 $steps = is_array( $steps ) ? $steps : [];
994
995 $pos_x = 383;
996 $pos_y = 143;
997 $pos_x_step = 243;
998 $node_id = 1;
999 $prev_id = null;
1000 $nodes = [];
1001
1002 foreach ( $steps as $step ) {
1003 $step_id = isset( $step['id'] ) ? (int) $step['id'] : 0;
1004 $step_type = isset( $step['step_type'] ) ? (string) $step['step_type'] : '';
1005 if ( ! $step_id || ! $step_type || 'addstep' === $step_type ) {
1006 continue;
1007 }
1008
1009 $node = [
1010 'id' => $node_id,
1011 'name' => $step_type,
1012 'data' => [
1013 'step_edit_link' => base64_encode( (string) get_edit_post_link( $step_id, 'raw' ) ),
1014 'step_type' => $step_type,
1015 'step_id' => $step_id,
1016 'step_view_link' => base64_encode( (string) get_post_permalink( $step_id ) ),
1017 ],
1018 'class' => $step_type,
1019 'html' => $step_type . $step_id,
1020 'typenode' => 'vue',
1021 'inputs' => [],
1022 'outputs' => [],
1023 'pos_x' => $pos_x,
1024 'pos_y' => $pos_y,
1025 ];
1026
1027 if ( null !== $prev_id ) {
1028 $node['inputs']['input_1'] = [
1029 'connections' => [ [ 'node' => $prev_id, 'input' => 'output_1' ] ],
1030 ];
1031 $nodes[ $prev_id ]['outputs']['output_1'] = [
1032 'connections' => [ [ 'node' => $node_id, 'output' => 'input_1' ] ],
1033 ];
1034 }
1035
1036 $nodes[ $node_id ] = $node;
1037 $prev_id = $node_id;
1038 $node_id++;
1039 $pos_x += $pos_x_step;
1040 }
1041
1042 // Trailing "+ Add step" ghost node — same as a brand-new/empty
1043 // canvas — so there's still somewhere to click to keep building.
1044 $ghost = [
1045 'id' => $node_id,
1046 'name' => 'addstep',
1047 'data' => [
1048 'step_type' => 'addstep',
1049 'node_identifier' => wp_rand( 100, 500 ),
1050 ],
1051 'class' => 'addstep',
1052 'html' => 'addstep' . $node_id,
1053 'typenode' => 'vue',
1054 'inputs' => [],
1055 'outputs' => [],
1056 'pos_x' => $pos_x,
1057 'pos_y' => $pos_y,
1058 ];
1059 if ( null !== $prev_id ) {
1060 $ghost['inputs']['input_1'] = [
1061 'connections' => [ [ 'node' => $prev_id, 'input' => 'output_1' ] ],
1062 ];
1063 $nodes[ $prev_id ]['outputs']['output_1'] = [
1064 'connections' => [ [ 'node' => $node_id, 'output' => 'input_1' ] ],
1065 ];
1066 }
1067 $nodes[ $node_id ] = $ghost;
1068
1069 update_post_meta(
1070 $funnel_id,
1071 '_funnel_data',
1072 [ 'drawflow' => [ 'Home' => [ 'data' => $nodes ] ] ]
1073 );
1074
1075 // _steps mirrors _steps_order — Funnel_Controller::prepare_funnel_data_response()
1076 // reads _steps (not _steps_order) for the response's `steps_order` field.
1077 update_post_meta( $funnel_id, '_steps', $steps );
1078 }
1079 }
1080