PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.0
5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 All 41 releases
double-opt-in / src / Admin / AdminRestController.php

AdminRestController.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.8.0, at src/Admin/AdminRestController.php

2,999 lines 95.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin REST Controller
4 *
5 * Consolidated REST API endpoints for the React admin SPA.
6 *
7 * @package Forge12\DoubleOptIn\Admin
8 * @since 4.2.0
9 */
10
11 namespace Forge12\DoubleOptIn\Admin;
12
13 use Forge12\DoubleOptIn\Addon\AddonRegistry;
14 use Forge12\DoubleOptIn\Audit\AuditLogger;
15 use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO;
16 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 use Forge12\DoubleOptIn\Integration\SubmittedContent;
19 use Forge12\DoubleOptIn\Service\ConfirmationMailResender;
20 use Forge12\DoubleOptIn\Service\ResendResult;
21 use Forge12\Shared\LoggerInterface;
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit;
25 }
26
27 /**
28 * Class AdminRestController
29 *
30 * REST API endpoints for the admin SPA.
31 */
32 class AdminRestController {
33
34 const API_NAMESPACE = 'f12-doi/v1';
35
36 private LoggerInterface $logger;
37 private FormSettingsService $formService;
38 private FormSettingsValidator $formValidator;
39
40 public function __construct(
41 LoggerInterface $logger,
42 FormSettingsService $formService,
43 FormSettingsValidator $formValidator
44 ) {
45 $this->logger = $logger;
46 $this->formService = $formService;
47 $this->formValidator = $formValidator;
48 }
49
50 /**
51 * Initialize REST routes.
52 */
53 public function init(): void {
54 add_action( 'rest_api_init', array( $this, 'registerRoutes' ) );
55 }
56
57 /**
58 * Permission callback.
59 */
60 public function checkPermission(): bool {
61 return current_user_can( 'manage_options' );
62 }
63
64 /**
65 * Permission callback for dev-only endpoints (currently the
66 * reset-confirmation route).
67 *
68 * Hard-locked behind WP_DEBUG so a production site running this
69 * codebase cannot reset confirmation status — that would silently
70 * erase legal Art-7 consent proof. Filterable for cases like CI
71 * test environments that want it gated by a different signal.
72 */
73 public function checkDevResetPermission(): bool {
74 $wpDebugOn = defined( 'WP_DEBUG' ) && WP_DEBUG === true;
75 /**
76 * Whether dev-only reset endpoints are reachable.
77 *
78 * @param bool $allowed Default: WP_DEBUG === true.
79 * @since 4.3.0
80 */
81 $allowed = (bool) apply_filters( 'f12_doi_dev_reset_allowed', $wpDebugOn );
82
83 return $allowed && current_user_can( 'manage_options' );
84 }
85
86 /**
87 * Register all REST API routes.
88 */
89 public function registerRoutes(): void {
90 // ── Dashboard ──────────────────────────────────────────────
91 register_rest_route(
92 self::API_NAMESPACE,
93 '/dashboard/stats',
94 array(
95 'methods' => \WP_REST_Server::READABLE,
96 'callback' => array( $this, 'getDashboardStats' ),
97 'permission_callback' => array( $this, 'checkPermission' ),
98 )
99 );
100
101 register_rest_route(
102 self::API_NAMESPACE,
103 '/dashboard/quick-info',
104 array(
105 'methods' => \WP_REST_Server::READABLE,
106 'callback' => array( $this, 'getDashboardQuickInfo' ),
107 'permission_callback' => array( $this, 'checkPermission' ),
108 )
109 );
110
111 // ── Opt-Ins ────────────────────────────────────────────────
112 register_rest_route(
113 self::API_NAMESPACE,
114 '/optins',
115 array(
116 'methods' => \WP_REST_Server::READABLE,
117 'callback' => array( $this, 'getOptins' ),
118 'permission_callback' => array( $this, 'checkPermission' ),
119 )
120 );
121
122 register_rest_route(
123 self::API_NAMESPACE,
124 '/optins/(?P<id>[\d]+)',
125 array(
126 'methods' => \WP_REST_Server::READABLE,
127 'callback' => array( $this, 'getOptin' ),
128 'permission_callback' => array( $this, 'checkPermission' ),
129 'args' => array(
130 'id' => array(
131 'validate_callback' => function ( $p ) {
132 return is_numeric( $p ); },
133 ),
134 ),
135 )
136 );
137
138 register_rest_route(
139 self::API_NAMESPACE,
140 '/optins/(?P<id>[\d]+)',
141 array(
142 'methods' => \WP_REST_Server::DELETABLE,
143 'callback' => array( $this, 'deleteOptin' ),
144 'permission_callback' => array( $this, 'checkPermission' ),
145 'args' => array(
146 'id' => array(
147 'validate_callback' => function ( $p ) {
148 return is_numeric( $p ); },
149 ),
150 ),
151 )
152 );
153
154 register_rest_route(
155 self::API_NAMESPACE,
156 '/optins/(?P<id>[\d]+)/resend',
157 array(
158 'methods' => \WP_REST_Server::CREATABLE,
159 'callback' => array( $this, 'resendOptinEmail' ),
160 'permission_callback' => array( $this, 'checkPermission' ),
161 'args' => array(
162 'id' => array(
163 'validate_callback' => function ( $p ) {
164 return is_numeric( $p ); },
165 ),
166 ),
167 )
168 );
169
170 // Dev-only: revert a confirmed opt-in to "pending" so the same
171 // confirmation link can be tested again without re-filling the
172 // form. Gated by WP_DEBUG — see checkDevResetPermission().
173 register_rest_route(
174 self::API_NAMESPACE,
175 '/optins/(?P<id>[\d]+)/reset-confirmation',
176 array(
177 'methods' => \WP_REST_Server::CREATABLE,
178 'callback' => array( $this, 'resetOptinConfirmation' ),
179 'permission_callback' => array( $this, 'checkDevResetPermission' ),
180 'args' => array(
181 'id' => array(
182 'validate_callback' => function ( $p ) {
183 return is_numeric( $p ); },
184 ),
185 ),
186 )
187 );
188
189 // ── Forms ──────────────────────────────────────────────────
190 register_rest_route(
191 self::API_NAMESPACE,
192 '/forms',
193 array(
194 'methods' => \WP_REST_Server::READABLE,
195 'callback' => array( $this, 'getForms' ),
196 'permission_callback' => array( $this, 'checkPermission' ),
197 )
198 );
199
200 register_rest_route(
201 self::API_NAMESPACE,
202 '/forms/(?P<integration>[a-z0-9_-]+)/(?P<form_id>[a-zA-Z0-9_-]+)/settings',
203 array(
204 'methods' => \WP_REST_Server::READABLE,
205 'callback' => array( $this, 'getFormSettings' ),
206 'permission_callback' => array( $this, 'checkPermission' ),
207 )
208 );
209
210 register_rest_route(
211 self::API_NAMESPACE,
212 '/forms/(?P<integration>[a-z0-9_-]+)/(?P<form_id>[a-zA-Z0-9_-]+)/settings',
213 array(
214 'methods' => \WP_REST_Server::EDITABLE,
215 'callback' => array( $this, 'saveFormSettings' ),
216 'permission_callback' => array( $this, 'checkPermission' ),
217 )
218 );
219
220 register_rest_route(
221 self::API_NAMESPACE,
222 '/forms/(?P<integration>[a-z0-9_-]+)/(?P<form_id>[a-zA-Z0-9_-]+)/toggle',
223 array(
224 'methods' => \WP_REST_Server::CREATABLE,
225 'callback' => array( $this, 'toggleForm' ),
226 'permission_callback' => array( $this, 'checkPermission' ),
227 )
228 );
229
230 register_rest_route(
231 self::API_NAMESPACE,
232 '/forms/(?P<integration>[a-z0-9_-]+)/(?P<form_id>[a-zA-Z0-9_-]+)/fields',
233 array(
234 'methods' => \WP_REST_Server::READABLE,
235 'callback' => array( $this, 'getFormFields' ),
236 'permission_callback' => array( $this, 'checkPermission' ),
237 )
238 );
239
240 // ── Settings ───────────────────────────────────────────────
241 register_rest_route(
242 self::API_NAMESPACE,
243 '/settings',
244 array(
245 'methods' => \WP_REST_Server::READABLE,
246 'callback' => array( $this, 'getSettings' ),
247 'permission_callback' => array( $this, 'checkPermission' ),
248 )
249 );
250
251 register_rest_route(
252 self::API_NAMESPACE,
253 '/settings',
254 array(
255 'methods' => \WP_REST_Server::EDITABLE,
256 'callback' => array( $this, 'updateSettings' ),
257 'permission_callback' => array( $this, 'checkPermission' ),
258 )
259 );
260
261 register_rest_route(
262 self::API_NAMESPACE,
263 '/settings/pages',
264 array(
265 'methods' => \WP_REST_Server::READABLE,
266 'callback' => array( $this, 'getPages' ),
267 'permission_callback' => array( $this, 'checkPermission' ),
268 )
269 );
270
271 register_rest_route(
272 self::API_NAMESPACE,
273 '/settings/email-templates-list',
274 array(
275 'methods' => \WP_REST_Server::READABLE,
276 'callback' => array( $this, 'getEmailTemplatesList' ),
277 'permission_callback' => array( $this, 'checkPermission' ),
278 )
279 );
280
281 // ── Categories ─────────────────────────────────────────────
282 register_rest_route(
283 self::API_NAMESPACE,
284 '/categories',
285 array(
286 'methods' => \WP_REST_Server::READABLE,
287 'callback' => array( $this, 'getCategories' ),
288 'permission_callback' => array( $this, 'checkPermission' ),
289 )
290 );
291
292 register_rest_route(
293 self::API_NAMESPACE,
294 '/categories',
295 array(
296 'methods' => \WP_REST_Server::CREATABLE,
297 'callback' => array( $this, 'createCategory' ),
298 'permission_callback' => array( $this, 'checkPermission' ),
299 )
300 );
301
302 register_rest_route(
303 self::API_NAMESPACE,
304 '/categories/(?P<id>[\d]+)',
305 array(
306 'methods' => \WP_REST_Server::EDITABLE,
307 'callback' => array( $this, 'updateCategory' ),
308 'permission_callback' => array( $this, 'checkPermission' ),
309 'args' => array(
310 'id' => array(
311 'validate_callback' => function ( $p ) {
312 return is_numeric( $p ); },
313 ),
314 ),
315 )
316 );
317
318 register_rest_route(
319 self::API_NAMESPACE,
320 '/categories/(?P<id>[\d]+)',
321 array(
322 'methods' => \WP_REST_Server::DELETABLE,
323 'callback' => array( $this, 'deleteCategory' ),
324 'permission_callback' => array( $this, 'checkPermission' ),
325 'args' => array(
326 'id' => array(
327 'validate_callback' => function ( $p ) {
328 return is_numeric( $p ); },
329 ),
330 ),
331 )
332 );
333
334 // ── Database ───────────────────────────────────────────────
335 register_rest_route(
336 self::API_NAMESPACE,
337 '/database/stats',
338 array(
339 'methods' => \WP_REST_Server::READABLE,
340 'callback' => array( $this, 'getDatabaseStats' ),
341 'permission_callback' => array( $this, 'checkPermission' ),
342 )
343 );
344
345 register_rest_route(
346 self::API_NAMESPACE,
347 '/database/clean',
348 array(
349 'methods' => \WP_REST_Server::CREATABLE,
350 'callback' => array( $this, 'cleanDatabase' ),
351 'permission_callback' => array( $this, 'checkPermission' ),
352 )
353 );
354
355 register_rest_route(
356 self::API_NAMESPACE,
357 '/database/reset',
358 array(
359 'methods' => \WP_REST_Server::CREATABLE,
360 'callback' => array( $this, 'resetDatabase' ),
361 'permission_callback' => array( $this, 'checkPermission' ),
362 )
363 );
364
365 // ── Audit Log ──────────────────────────────────────────────
366 register_rest_route(
367 self::API_NAMESPACE,
368 '/audit/events',
369 array(
370 'methods' => \WP_REST_Server::READABLE,
371 'callback' => array( $this, 'getAuditEvents' ),
372 'permission_callback' => array( $this, 'checkPermission' ),
373 )
374 );
375
376 register_rest_route(
377 self::API_NAMESPACE,
378 '/audit/summary',
379 array(
380 'methods' => \WP_REST_Server::READABLE,
381 'callback' => array( $this, 'getAuditSummary' ),
382 'permission_callback' => array( $this, 'checkPermission' ),
383 )
384 );
385
386 // ── Analytics (Pro-extensible) ─────────────────────────────
387 register_rest_route(
388 self::API_NAMESPACE,
389 '/analytics/overview',
390 array(
391 'methods' => \WP_REST_Server::READABLE,
392 'callback' => array( $this, 'getAnalyticsOverview' ),
393 'permission_callback' => array( $this, 'checkPermission' ),
394 )
395 );
396
397 register_rest_route(
398 self::API_NAMESPACE,
399 '/analytics/form/(?P<form_id>[\d]+)',
400 array(
401 'methods' => \WP_REST_Server::READABLE,
402 'callback' => array( $this, 'getAnalyticsForm' ),
403 'permission_callback' => array( $this, 'checkPermission' ),
404 'args' => array(
405 'form_id' => array(
406 'validate_callback' => function ( $p ) {
407 return is_numeric( $p ); },
408 ),
409 ),
410 )
411 );
412
413 // ── Opt-Out Settings (Pro-extensible) ──────────────────────
414 register_rest_route(
415 self::API_NAMESPACE,
416 '/optout/settings',
417 array(
418 array(
419 'methods' => \WP_REST_Server::READABLE,
420 'callback' => array( $this, 'getOptoutSettings' ),
421 'permission_callback' => array( $this, 'checkPermission' ),
422 ),
423 array(
424 'methods' => \WP_REST_Server::EDITABLE,
425 'callback' => array( $this, 'updateOptoutSettings' ),
426 'permission_callback' => array( $this, 'checkPermission' ),
427 ),
428 )
429 );
430
431 // One-click opt-out page generator. Creates a WP page with the
432 // required list+form shortcodes so the admin doesn't have to
433 // hop over to Pages → New manually. Idempotent: a page that
434 // already contains `[f12-cf7-doubleoptin-optout-list]` is
435 // returned instead of duplicated.
436 register_rest_route(
437 self::API_NAMESPACE,
438 '/optout/page/generate',
439 array(
440 array(
441 'methods' => \WP_REST_Server::CREATABLE,
442 'callback' => array( $this, 'generateOptoutPage' ),
443 'permission_callback' => array( $this, 'checkPermission' ),
444 ),
445 )
446 );
447
448 // ── User Creation Settings (Pro-extensible) ────────────────
449 register_rest_route(
450 self::API_NAMESPACE,
451 '/user-creation/settings',
452 array(
453 array(
454 'methods' => \WP_REST_Server::READABLE,
455 'callback' => array( $this, 'getUserCreationSettings' ),
456 'permission_callback' => array( $this, 'checkPermission' ),
457 ),
458 array(
459 'methods' => \WP_REST_Server::EDITABLE,
460 'callback' => array( $this, 'updateUserCreationSettings' ),
461 'permission_callback' => array( $this, 'checkPermission' ),
462 ),
463 )
464 );
465
466 // ── API Settings (Pro-extensible) ──────────────────────────
467 register_rest_route(
468 self::API_NAMESPACE,
469 '/api/settings',
470 array(
471 array(
472 'methods' => \WP_REST_Server::READABLE,
473 'callback' => array( $this, 'getApiSettings' ),
474 'permission_callback' => array( $this, 'checkPermission' ),
475 ),
476 array(
477 'methods' => \WP_REST_Server::EDITABLE,
478 'callback' => array( $this, 'updateApiSettings' ),
479 'permission_callback' => array( $this, 'checkPermission' ),
480 ),
481 )
482 );
483
484 // ── License (Pro-extensible) ───────────────────────────────
485 register_rest_route(
486 self::API_NAMESPACE,
487 '/license',
488 array(
489 'methods' => \WP_REST_Server::READABLE,
490 'callback' => array( $this, 'getLicense' ),
491 'permission_callback' => array( $this, 'checkPermission' ),
492 )
493 );
494
495 register_rest_route(
496 self::API_NAMESPACE,
497 '/license/activate',
498 array(
499 'methods' => \WP_REST_Server::CREATABLE,
500 'callback' => array( $this, 'activateLicense' ),
501 'permission_callback' => array( $this, 'checkPermission' ),
502 )
503 );
504
505 register_rest_route(
506 self::API_NAMESPACE,
507 '/license/deactivate',
508 array(
509 'methods' => \WP_REST_Server::CREATABLE,
510 'callback' => array( $this, 'deactivateLicense' ),
511 'permission_callback' => array( $this, 'checkPermission' ),
512 )
513 );
514
515 // ── Addons manifest (UI mount-point system, plan §9) ────────
516 register_rest_route(
517 self::API_NAMESPACE,
518 '/addons',
519 array(
520 'methods' => \WP_REST_Server::READABLE,
521 'callback' => array( $this, 'getAddonsManifest' ),
522 'permission_callback' => array( $this, 'checkPermission' ),
523 )
524 );
525
526 // ── Addons catalog (marketplace view + state) ───────────────
527 register_rest_route(
528 self::API_NAMESPACE,
529 '/addons/catalog',
530 array(
531 'methods' => \WP_REST_Server::READABLE,
532 'callback' => array( $this, 'getAddonCatalog' ),
533 'permission_callback' => array( $this, 'checkPermission' ),
534 )
535 );
536
537 // ── Per-addon activate (calls activate_plugin in Core) ──────
538 register_rest_route(
539 self::API_NAMESPACE,
540 '/addons/(?P<id>[a-z0-9-]+)/activate',
541 array(
542 'methods' => \WP_REST_Server::CREATABLE,
543 'callback' => array( $this, 'activateAddon' ),
544 'permission_callback' => function () {
545 return current_user_can( 'activate_plugins' );
546 },
547 'args' => array(
548 'id' => array(
549 'required' => true,
550 'sanitize_callback' => 'sanitize_key',
551 ),
552 ),
553 )
554 );
555
556 // ── Per-addon deactivate (mirror of /activate) ──────────────
557 register_rest_route(
558 self::API_NAMESPACE,
559 '/addons/(?P<id>[a-z0-9-]+)/deactivate',
560 array(
561 'methods' => \WP_REST_Server::CREATABLE,
562 'callback' => array( $this, 'deactivateAddon' ),
563 'permission_callback' => function () {
564 return current_user_can( 'activate_plugins' );
565 },
566 'args' => array(
567 'id' => array(
568 'required' => true,
569 'sanitize_callback' => 'sanitize_key',
570 ),
571 ),
572 )
573 );
574
575 // ── Per-addon settings GET/POST (feature-level toggle + addon-specific settings) ──
576 // Distinct from plugin activation: the addon plugin file can be
577 // active in WP while the user temporarily turns the feature off
578 // here. Each addon stores its settings under a dedicated WP
579 // option (`f12_doi_addon_{id}_settings`); the addon's own hooks
580 // read from that option to gate their behaviour.
581 register_rest_route(
582 self::API_NAMESPACE,
583 '/addons/(?P<id>[a-z0-9-]+)/settings',
584 array(
585 array(
586 'methods' => \WP_REST_Server::READABLE,
587 'callback' => array( $this, 'getAddonSettings' ),
588 'permission_callback' => array( $this, 'checkPermission' ),
589 'args' => array(
590 'id' => array(
591 'required' => true,
592 'sanitize_callback' => 'sanitize_key',
593 ),
594 ),
595 ),
596 array(
597 'methods' => \WP_REST_Server::CREATABLE,
598 'callback' => array( $this, 'updateAddonSettings' ),
599 'permission_callback' => array( $this, 'checkPermission' ),
600 'args' => array(
601 'id' => array(
602 'required' => true,
603 'sanitize_callback' => 'sanitize_key',
604 ),
605 ),
606 ),
607 )
608 );
609 }
610
611 // ═══════════════════════════════════════════════════════════════
612 // DASHBOARD
613 // ═══════════════════════════════════════════════════════════════
614
615 public function getDashboardStats( \WP_REST_Request $request ): \WP_REST_Response {
616 global $wpdb;
617 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
618
619 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
620 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
621 $pending = $total - $confirmed;
622 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
623
624 // Recent opt-ins (raw activity feed — not analytics).
625 // Time-bucketed activity, top-forms breakdown and the big
626 // conversion-rate card moved into addon-analytics, which
627 // renders them at the `dashboard.widget` mount point.
628 $recent = $wpdb->get_results(
629 "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5",
630 ARRAY_A
631 );
632
633 foreach ( $recent as &$row ) {
634 $post = get_post( (int) $row['cf_form_id'] );
635 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
636 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
637 }
638
639 $data = array(
640 'totalOptins' => $total,
641 'confirmed' => $confirmed,
642 'pending' => $pending,
643 'conversionRate' => $rate,
644 'recentOptins' => $recent ?: array(),
645 );
646
647 /**
648 * Filter dashboard stats so Pro can add data.
649 *
650 * @param array $data The dashboard data.
651 * @since 4.2.0
652 */
653 $data = apply_filters( 'f12_doi_rest_dashboard_stats', $data );
654
655 return new \WP_REST_Response(
656 array(
657 'success' => true,
658 'data' => $data,
659 ),
660 200
661 );
662 }
663
664 public function getDashboardQuickInfo( \WP_REST_Request $request ): \WP_REST_Response {
665 $settings = get_option( 'f12-doi-settings', array() );
666
667 $info = array(
668 'version' => defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : '0.0.0',
669 'tokenExpiry' => (int) ( $settings['token_expiry_hours'] ?? 48 ),
670 'retention' => ( $settings['delete'] ?? 12 ) . ' ' . ( $settings['delete_period'] ?? 'months' ),
671 'rateLimit' => (int) ( $settings['rate_limit_ip'] ?? 5 ),
672 'licenseStatus' => apply_filters( 'f12_doi_is_pro_active', false ) ? 'Pro Active' : 'Free',
673 );
674
675 /**
676 * Filter quick info so Pro can add license data.
677 *
678 * @param array $info The quick info data.
679 * @since 4.2.0
680 */
681 $info = apply_filters( 'f12_doi_rest_dashboard_info', $info );
682
683 return new \WP_REST_Response(
684 array(
685 'success' => true,
686 'data' => $info,
687 ),
688 200
689 );
690 }
691
692 // ═══════════════════════════════════════════════════════════════
693 // OPT-INS
694 // ═══════════════════════════════════════════════════════════════
695
696 public function getOptins( \WP_REST_Request $request ): \WP_REST_Response {
697 $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
698 $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) );
699 $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
700 $category = $request->get_param( 'category' );
701 $status = sanitize_text_field( $request->get_param( 'status' ) ?? '' );
702 $formId = $request->get_param( 'form_id' );
703
704 global $wpdb;
705 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
706 $where = array( '1=1' );
707 $params = array();
708
709 if ( ! empty( $search ) ) {
710 $where[] = '(email LIKE %s OR hash LIKE %s)';
711 $like = '%' . $wpdb->esc_like( $search ) . '%';
712 $params[] = $like;
713 $params[] = $like;
714 }
715
716 if ( $category !== null && $category !== '' ) {
717 $where[] = 'category = %d';
718 $params[] = (int) $category;
719 }
720
721 if ( $status === 'confirmed' ) {
722 $where[] = 'doubleoptin = 1';
723 } elseif ( $status === 'pending' ) {
724 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
725 }
726
727 if ( $formId !== null && $formId !== '' ) {
728 $where[] = 'cf_form_id = %d';
729 $params[] = (int) $formId;
730 }
731
732 // Opt-ins whose confirmation mail could not be sent (5.8.0).
733 if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
734 $where[] = 'mail_status = %s';
735 $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
736 }
737
738 // Confirmed opt-ins whose follow-up actions failed or have an
739 // unknown outcome — the admin's "needs attention" list.
740 if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
741 $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
742 $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic();
743 $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN ("
744 . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))';
745 $params = array_merge( $params, $problems );
746 }
747
748 $whereClause = implode( ' AND ', $where );
749
750 // Count
751 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
752 if ( ! empty( $params ) ) {
753 $countQuery = $wpdb->prepare( $countQuery, $params );
754 }
755 $total = (int) $wpdb->get_var( $countQuery );
756
757 // Fetch
758 $offset = ( $page - 1 ) * $perPage;
759 $query = "SELECT * FROM {$table} WHERE {$whereClause} ORDER BY id DESC LIMIT %d OFFSET %d";
760 $allParams = array_merge( $params, array( $perPage, $offset ) );
761 $rows = $wpdb->get_results( $wpdb->prepare( $query, $allParams ), ARRAY_A );
762
763 $optins = array();
764 foreach ( $rows as $row ) {
765 $optins[] = $this->formatOptinRow( $row );
766 }
767
768 return new \WP_REST_Response(
769 array(
770 'success' => true,
771 'data' => array(
772 'items' => $optins,
773 'total' => $total,
774 'pages' => (int) ceil( $total / $perPage ),
775 'page' => $page,
776 'perPage' => $perPage,
777 ),
778 ),
779 200
780 );
781 }
782
783 public function getOptin( \WP_REST_Request $request ): \WP_REST_Response {
784 global $wpdb;
785 $id = (int) $request->get_param( 'id' );
786 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
787
788 $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
789
790 if ( ! $row ) {
791 return new \WP_REST_Response(
792 array(
793 'success' => false,
794 'message' => __( 'Opt-In not found.', 'double-opt-in' ),
795 ),
796 404
797 );
798 }
799
800 $data = $this->formatOptinRow( $row, true );
801
802 // Dev-mode UI hint: surface whether the reset-confirmation
803 // endpoint is reachable for this request, so the React detail
804 // page can show/hide the "Reset to pending" button without
805 // having to probe the endpoint and handle a 403. Mirrors
806 // the same WP_DEBUG + capability gate as the endpoint itself.
807 $data['_devResetAvailable'] = $this->checkDevResetPermission();
808
809 /**
810 * Filter single optin response so Pro can add reminder/optout data.
811 *
812 * @param array $data The optin data.
813 * @param int $optinId The optin ID.
814 * @since 4.2.0
815 */
816 $data = apply_filters( 'f12_doi_rest_optin_response', $data, $id );
817
818 return new \WP_REST_Response(
819 array(
820 'success' => true,
821 'data' => $data,
822 ),
823 200
824 );
825 }
826
827 public function deleteOptin( \WP_REST_Request $request ): \WP_REST_Response {
828 global $wpdb;
829 $id = (int) $request->get_param( 'id' );
830 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
831
832 // Pre-fetch the row so post-delete listeners (file-storage
833 // cleanup, addon cleanup hooks) get the data they need to
834 // locate per-OptIn artifacts. Pre-fix the REST endpoint
835 // silently bypassed the deletion-event pipeline that the cron
836 // + manual-hash paths use — addons hooking
837 // f12_cf7_doubleoptin_deleted got coverage gaps for any opt-in
838 // the admin removed via the React Trash button.
839 //
840 // Full row (id, hash, content, files, cf_form_id) so the
841 // pre-delete cascade hook from pre-doi-data-retention Step 1
842 // can fire with a payload that lets listeners reach into
843 // integration storage. ARRAY_A — listener-friendly.
844 $row = $wpdb->get_row(
845 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ),
846 ARRAY_A
847 );
848 $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null;
849
850 // Pre-delete cascade — fires before the DELETE so listeners
851 // read the row's payload to cascade form-system cleanup. Mirror
852 // of CleanUp::removeOlderThan + delete_optin_by_hash. See
853 // plan/pre-doi-data-retention.md.
854 if ( is_array( $row ) ) {
855 do_action( 'f12_doi_optin_pre_delete', $row );
856 }
857
858 $result = $wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) );
859
860 if ( $result === false ) {
861 return new \WP_REST_Response(
862 array(
863 'success' => false,
864 'message' => __( 'Failed to delete opt-in.', 'double-opt-in' ),
865 ),
866 500
867 );
868 }
869
870 // Fire the deletion event ONLY if a row actually existed and
871 // was removed. Idempotent retries (DELETE on a non-existent
872 // id) silently succeed at the wpdb layer with $result=0 — but
873 // dispatching an event for a no-op deletion would mislead any
874 // listener doing aggregate counting / file cleanup.
875 if ( $hash && (int) $result > 0 ) {
876 $cleanup = new \forge12\contactform7\CF7DoubleOptIn\CleanUp(
877 \Forge12\Shared\Logger::getInstance()
878 );
879 $cleanup->dispatchOptInDeletedEvent(
880 (string) $hash,
881 'manual_rest',
882 get_current_user_id() ?: null
883 );
884 }
885
886 AuditLogger::log(
887 AuditLogger::TYPE_SETTINGS,
888 AuditLogger::SEVERITY_INFO,
889 sprintf(
890 __( 'Opt-in #%d deleted.', 'double-opt-in' ),
891 $id
892 )
893 );
894
895 return new \WP_REST_Response(
896 array(
897 'success' => true,
898 'message' => __( 'Opt-In deleted.', 'double-opt-in' ),
899 ),
900 200
901 );
902 }
903
904 /**
905 * The admin's answer for a resend that did not go out.
906 */
907 private static function resendRefusal( string $reason ): \WP_REST_Response {
908 $map = array(
909 ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ),
910 ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ),
911 ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ),
912 ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ),
913 ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ),
914 );
915 $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 );
916 $message = $entry[0];
917 $status = $entry[1];
918
919 return new \WP_REST_Response(
920 array(
921 'success' => false,
922 'message' => $message,
923 ),
924 $status
925 );
926 }
927
928 public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response {
929 global $wpdb;
930 $id = (int) $request->get_param( 'id' );
931 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
932
933 $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
934
935 if ( ! $row ) {
936 return new \WP_REST_Response(
937 array(
938 'success' => false,
939 'message' => __( 'Opt-In not found.', 'double-opt-in' ),
940 ),
941 404
942 );
943 }
944
945 if ( (int) $row['doubleoptin'] === 1 ) {
946 return new \WP_REST_Response(
947 array(
948 'success' => false,
949 'message' => __( 'Opt-In is already confirmed.', 'double-opt-in' ),
950 ),
951 400
952 );
953 }
954
955 // Try to get the OptIn object via hash for filter compatibility
956 $optin = \forge12\contactform7\CF7DoubleOptIn\OptIn::get_by_hash( $row['hash'] );
957
958 /**
959 * Allow Pro or other extensions to handle the actual resend.
960 *
961 * @param bool|null $result Null = not handled, true = sent, false = failed.
962 * @param object $optin The OptIn instance (or null).
963 * @param array $row The raw database row.
964 * @since 4.2.0
965 */
966 $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row );
967
968 if ( $result === null ) {
969 $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance()
970 ->get( ConfirmationMailResender::class )
971 ->resend( $id );
972
973 if ( ! $outcome->isSent() ) {
974 return self::resendRefusal( $outcome->getReason() );
975 }
976 $result = true;
977 } else {
978 // An extension sent it; record the outcome all the same.
979 do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' );
980 }
981
982 if ( ! $result ) {
983 return self::resendRefusal( ResendResult::SEND_FAILED );
984 }
985
986 AuditLogger::log(
987 AuditLogger::TYPE_EMAIL,
988 AuditLogger::SEVERITY_INFO,
989 sprintf(
990 __( 'Confirmation email resent for opt-in #%d.', 'double-opt-in' ),
991 $id
992 )
993 );
994
995 return new \WP_REST_Response(
996 array(
997 'success' => true,
998 'message' => __( 'Confirmation email resent.', 'double-opt-in' ),
999 ),
1000 200
1001 );
1002 }
1003
1004 /**
1005 * Dev-only: revert a confirmed opt-in to "pending".
1006 *
1007 * Lets developers click the same confirmation link multiple times
1008 * during integration testing without re-filling the source form.
1009 * Permission is locked behind WP_DEBUG via checkDevResetPermission()
1010 * — this MUST NOT be reachable in production: resetting an opt-in's
1011 * doubleoptin flag drops legal Art.7 consent state.
1012 *
1013 * Side-effect intentional: any submission/entry rows that the
1014 * Avada (or future) replay wrote on the previous confirmation are
1015 * left alone. They become orphan dev-noise, deletable via Avada's
1016 * own Form Entries UI. Cleaning them up here would require knowing
1017 * the integration-specific cleanup path for every addon, which is
1018 * scope-creep for a debugging convenience.
1019 */
1020 public function resetOptinConfirmation( \WP_REST_Request $request ): \WP_REST_Response {
1021 global $wpdb;
1022 $id = (int) $request->get_param( 'id' );
1023 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
1024
1025 $row = $wpdb->get_row( $wpdb->prepare( "SELECT id, hash, doubleoptin FROM {$table} WHERE id = %d", $id ), ARRAY_A );
1026
1027 if ( ! $row ) {
1028 return new \WP_REST_Response(
1029 array(
1030 'success' => false,
1031 'message' => __( 'Opt-In not found.', 'double-opt-in' ),
1032 ),
1033 404
1034 );
1035 }
1036
1037 if ( (int) $row['doubleoptin'] !== 1 ) {
1038 // Already pending — nothing to do, return success so the
1039 // React UI's idempotent retry behaves cleanly.
1040 return new \WP_REST_Response(
1041 array(
1042 'success' => true,
1043 'message' => __( 'Opt-In is already pending.', 'double-opt-in' ),
1044 ),
1045 200
1046 );
1047 }
1048
1049 $result = $wpdb->update(
1050 $table,
1051 array( 'doubleoptin' => 0 ),
1052 array( 'id' => $id ),
1053 array( '%d' ),
1054 array( '%d' )
1055 );
1056
1057 if ( $result === false ) {
1058 return new \WP_REST_Response(
1059 array(
1060 'success' => false,
1061 'message' => __( 'Failed to reset confirmation status.', 'double-opt-in' ),
1062 ),
1063 500
1064 );
1065 }
1066
1067 AuditLogger::log(
1068 AuditLogger::TYPE_SETTINGS,
1069 AuditLogger::SEVERITY_WARNING,
1070 sprintf(
1071 /* translators: %d: opt-in id */
1072 __( 'DEV: Opt-in #%d confirmation reset (WP_DEBUG mode).', 'double-opt-in' ),
1073 $id
1074 )
1075 );
1076
1077 /**
1078 * Fires after a dev-mode reset. Addons can use this to clear
1079 * any side-effect rows they wrote on confirmation (e.g. the
1080 * Avada submission/entries replay) so a re-confirmation starts
1081 * from a clean slate.
1082 *
1083 * @param int $id The opt-in id whose confirmation was reset.
1084 * @param string $hash The opt-in's confirmation hash.
1085 * @since 4.3.0
1086 */
1087 do_action( 'f12_doi_optin_confirmation_reset', $id, $row['hash'] );
1088
1089 return new \WP_REST_Response(
1090 array(
1091 'success' => true,
1092 'message' => __( 'Confirmation reset to pending. Click the confirmation link again to re-test.', 'double-opt-in' ),
1093 ),
1094 200
1095 );
1096 }
1097
1098 // ═══════════════════════════════════════════════════════════════
1099 // FORMS
1100 // ═══════════════════════════════════════════════════════════════
1101
1102 public function getForms( \WP_REST_Request $request ): \WP_REST_Response {
1103 $forms = $this->formService->getAllForms();
1104
1105 // Enrich each form with completeness data so FormsPage can
1106 // render the "Konfiguration unvollständig"-Badge and disable
1107 // the toggle for forms whose config blocks activation
1108 // (plan/doi-completeness-gate.md §2.5).
1109 foreach ( $forms as $integrationKey => &$integrationData ) {
1110 if ( ! isset( $integrationData['forms'] ) || ! is_array( $integrationData['forms'] ) ) {
1111 continue;
1112 }
1113 foreach ( $integrationData['forms'] as &$form ) {
1114 $formId = $form['id'] ?? null;
1115 $storageId = is_string( $formId ) && strpos( $formId, '_' ) !== false
1116 ? (int) explode( '_', $formId )[0]
1117 : (int) $formId;
1118 if ( $storageId <= 0 ) {
1119 $form['isComplete'] = false;
1120 $form['missingFields'] = array();
1121 $form['enabled'] = false;
1122 continue;
1123 }
1124
1125 $dto = $this->formService->getSettings( $storageId );
1126 $missing = $dto->getMissingRequiredFields();
1127 $form['isComplete'] = empty( $missing );
1128 $form['missingFields'] = array_values( $missing );
1129
1130 // Completeness-gate override (user-reported 2026-05-13):
1131 // the Integration's getForms() reads the raw `enable=1`
1132 // from post-meta. A form whose recipient field was
1133 // removed AFTER it was originally enabled stays
1134 // `enable=1` in storage, so the list view used to
1135 // render it as active green-checkmark while the detail
1136 // view showed it as disabled (the latter applies the
1137 // same gate that REST save + the completeness-sweep
1138 // migration apply). Both surfaces now agree: an
1139 // incomplete form is effectively inactive, period.
1140 // Storage stays as-is so the user's intent survives —
1141 // once they fix the missing field, the gate clears and
1142 // the form goes back to its stored enabled state.
1143 if ( ! empty( $missing ) ) {
1144 $form['enabled'] = false;
1145 }
1146 }
1147 unset( $form );
1148 }
1149 unset( $integrationData );
1150
1151 $data = apply_filters( 'f12_doi_rest_forms_response', $forms );
1152
1153 return new \WP_REST_Response(
1154 array(
1155 'success' => true,
1156 'data' => $data,
1157 ),
1158 200
1159 );
1160 }
1161
1162 public function getFormSettings( \WP_REST_Request $request ): \WP_REST_Response {
1163 $formId = sanitize_text_field( $request->get_param( 'form_id' ) );
1164 $integration = sanitize_text_field( $request->get_param( 'integration' ) );
1165
1166 $formData = $this->formService->getFormData( $formId, $integration );
1167
1168 if ( ! $formData ) {
1169 return new \WP_REST_Response(
1170 array(
1171 'success' => false,
1172 'message' => __( 'Form not found.', 'double-opt-in' ),
1173 ),
1174 404
1175 );
1176 }
1177
1178 // Add dropdown data
1179 $formData['templates'] = $this->formService->getAvailableTemplates( $formId );
1180 $formData['categories'] = $this->formService->getAvailableCategories();
1181 $formData['pages'] = $this->formService->getAvailablePages();
1182 $formData['templateDetails'] = $this->formService->getTemplateDetails();
1183
1184 /**
1185 * Filter form settings response so Pro can add data.
1186 *
1187 * @param array $formData The form data.
1188 * @param string|int $formId The form ID.
1189 * @param string $integration The integration identifier.
1190 * @since 4.2.0
1191 */
1192 $formData = apply_filters( 'f12_doi_rest_form_settings_response', $formData, $formId, $integration );
1193
1194 return new \WP_REST_Response(
1195 array(
1196 'success' => true,
1197 'data' => $formData,
1198 ),
1199 200
1200 );
1201 }
1202
1203 public function saveFormSettings( \WP_REST_Request $request ): \WP_REST_Response {
1204 $formId = sanitize_text_field( $request->get_param( 'form_id' ) );
1205 $integration = sanitize_text_field( $request->get_param( 'integration' ) );
1206 $input = $request->get_json_params();
1207
1208 if ( empty( $formId ) ) {
1209 return new \WP_REST_Response(
1210 array(
1211 'success' => false,
1212 'message' => __( 'Invalid form ID.', 'double-opt-in' ),
1213 ),
1214 400
1215 );
1216 }
1217
1218 // For composite IDs (Elementor), extract post ID for storage
1219 $storageId = strpos( $formId, '_' ) !== false ? (int) explode( '_', $formId )[0] : (int) $formId;
1220
1221 // Capture enabled-state BEFORE sanitize for the completeness-gate
1222 // (plan §2.2). Same shape as FormSettingsController; both
1223 // endpoints must enforce the same gate so the React UI sees
1224 // uniform behaviour whichever path it happens to use.
1225 $oldSettings = $this->formService->getSettings( $storageId );
1226 $wasEnabled = $oldSettings->enabled;
1227
1228 // Sanitize and create DTO
1229 $settingsData = $input['settings'] ?? $input;
1230 $settings = $this->formValidator->sanitize( $settingsData );
1231
1232 // Completeness-gate
1233 $missingRequired = $settings->getMissingRequiredFields();
1234 $autoDisabled = false;
1235
1236 if ( $settings->enabled && ! empty( $missingRequired ) ) {
1237 if ( ! $wasEnabled ) {
1238 return $this->incompleteConfigResponse( $missingRequired );
1239 }
1240 // Was enabled, save makes it incomplete — auto-disable so
1241 // the user's other edits land but the form stops misfiring.
1242 $settings->enabled = false;
1243 $autoDisabled = true;
1244
1245 do_action( 'f12_doi_form_auto_disabled_incomplete', $formId, $missingRequired );
1246
1247 $this->logger->warning(
1248 'Form auto-disabled — REST save would have left it enabled with incomplete config',
1249 array(
1250 'plugin' => 'double-opt-in',
1251 'form_id' => $formId,
1252 'missing' => $missingRequired,
1253 )
1254 );
1255 }
1256
1257 // Format-only validation (sender email format, page/category existence)
1258 $errors = $this->formValidator->validate( $settings );
1259 if ( ! empty( $errors ) ) {
1260 return new \WP_REST_Response(
1261 array(
1262 'success' => false,
1263 'message' => __( 'Validation failed.', 'double-opt-in' ),
1264 'errors' => $errors,
1265 ),
1266 400
1267 );
1268 }
1269
1270 /**
1271 * Filter to allow Pro to modify settings before saving.
1272 *
1273 * @param FormSettingsDTO $settings The settings DTO.
1274 * @param int $storageId The storage ID.
1275 * @param array $settingsData The raw input data.
1276 * @since 4.2.0
1277 */
1278 $settings = apply_filters( 'f12_doi_rest_form_settings_save', $settings, $storageId, $settingsData );
1279
1280 $result = $this->formService->saveSettings( $storageId, $settings );
1281
1282 if ( ! $result ) {
1283 return new \WP_REST_Response(
1284 array(
1285 'success' => false,
1286 'message' => __( 'Failed to save settings.', 'double-opt-in' ),
1287 ),
1288 500
1289 );
1290 }
1291
1292 do_action( 'f12_doi_form_settings_saved', $formId, $settings, $settingsData, $integration );
1293
1294 return new \WP_REST_Response(
1295 array(
1296 'success' => true,
1297 'message' => $autoDisabled
1298 ? __( 'Settings saved. Double Opt-In was auto-disabled because the configuration is incomplete.', 'double-opt-in' )
1299 : __( 'Settings saved successfully.', 'double-opt-in' ),
1300 'data' => array(
1301 'enabled' => $settings->enabled,
1302 'autoDisabled' => $autoDisabled,
1303 'missing' => array_values( $missingRequired ),
1304 ),
1305 ),
1306 200
1307 );
1308 }
1309
1310 public function toggleForm( \WP_REST_Request $request ): \WP_REST_Response {
1311 $formId = sanitize_text_field( $request->get_param( 'form_id' ) );
1312 $integration = sanitize_text_field( $request->get_param( 'integration' ) );
1313
1314 $storageId = strpos( $formId, '_' ) !== false ? (int) explode( '_', $formId )[0] : (int) $formId;
1315
1316 // Completeness-gate before toggle-to-enabled (plan §2.3).
1317 // Toggling-to-disabled is always allowed.
1318 $currentSettings = $this->formService->getSettings( $storageId );
1319 if ( ! $currentSettings->enabled ) {
1320 $missing = $currentSettings->getMissingRequiredFields();
1321 if ( ! empty( $missing ) ) {
1322 return $this->incompleteConfigResponse( $missing );
1323 }
1324 }
1325
1326 $newState = $this->formService->toggleEnabled( $storageId );
1327
1328 do_action( 'f12_doi_form_toggled', $formId, $newState, $integration );
1329
1330 return new \WP_REST_Response(
1331 array(
1332 'success' => true,
1333 'data' => array(
1334 'enabled' => $newState,
1335 'message' => $newState
1336 ? __( 'Double Opt-In enabled.', 'double-opt-in' )
1337 : __( 'Double Opt-In disabled.', 'double-opt-in' ),
1338 ),
1339 ),
1340 200
1341 );
1342 }
1343
1344 /**
1345 * Build the structured 422 INCOMPLETE_CONFIG response shared by
1346 * {@see saveFormSettings()} and {@see toggleForm()}.
1347 *
1348 * Per plan/doi-completeness-gate.md §2.2 + §2.3. Distinct code so
1349 * the React UI can pattern-match on `code === 'INCOMPLETE_CONFIG'`
1350 * for the Toast affordance (§2.7) instead of falling through to
1351 * generic field-level error rendering.
1352 *
1353 * @param array<int,string> $missing Stable required-field IDs.
1354 */
1355 private function incompleteConfigResponse( array $missing ): \WP_REST_Response {
1356 return new \WP_REST_Response(
1357 array(
1358 'success' => false,
1359 'code' => 'INCOMPLETE_CONFIG',
1360 'message' => __(
1361 'Cannot enable Double Opt-In: configuration is incomplete. Please fill in all required fields first.',
1362 'double-opt-in'
1363 ),
1364 'missing' => array_values( $missing ),
1365 ),
1366 422
1367 );
1368 }
1369
1370 public function getFormFields( \WP_REST_Request $request ): \WP_REST_Response {
1371 $formId = sanitize_text_field( $request->get_param( 'form_id' ) );
1372 $integration = sanitize_text_field( $request->get_param( 'integration' ) );
1373
1374 $formData = $this->formService->getFormData( $formId, $integration );
1375
1376 if ( ! $formData ) {
1377 return new \WP_REST_Response(
1378 array(
1379 'success' => false,
1380 'message' => __( 'Form not found.', 'double-opt-in' ),
1381 ),
1382 404
1383 );
1384 }
1385
1386 return new \WP_REST_Response(
1387 array(
1388 'success' => true,
1389 'data' => $formData['fields'] ?? array(),
1390 ),
1391 200
1392 );
1393 }
1394
1395 // ═══════════════════════════════════════════════════════════════
1396 // SETTINGS
1397 // ═══════════════════════════════════════════════════════════════
1398
1399 public function getSettings( \WP_REST_Request $request ): \WP_REST_Response {
1400 $defaults = array(
1401 'telemetry' => 1,
1402 // Optional "Double Opt-In by Forge12" credit on the confirmation
1403 // page. Defaults to 0 and must stay that way: wordpress.org
1404 // guideline 10 requires credit links to be off unless the site
1405 // owner explicitly turns them on.
1406 'credit_link' => 0,
1407 'delete' => 12,
1408 'delete_unconfirmed' => 7,
1409 'delete_period' => 'months',
1410 'delete_unconfirmed_period' => 'months',
1411 'privacy_policy_page' => 0,
1412 'token_expiry_hours' => 48,
1413 'rate_limit_ip' => 5,
1414 'rate_limit_email' => 3,
1415 'rate_limit_window' => 60,
1416 // Preserve opt-in data when the plugin is deleted. Defaults to 1
1417 // (keep) so deleting the plugin never silently destroys GDPR
1418 // consent records; admins can opt into a full cleanup. Read by
1419 // uninstall.php.
1420 'keep_data_on_uninstall' => 1,
1421 // Pro defaults (will be overridden by f12_doi_rest_settings_response filter if Pro is active)
1422 'reminder_enabled' => 0,
1423 'reminder_delay' => 24,
1424 'reminder_subject' => '',
1425 'reminder_template' => '',
1426 'mx_validation_enabled' => 0,
1427 'mx_validation_behavior' => 'silent',
1428 'mx_validation_message' => '',
1429 'domain_blocklist_enabled' => 0,
1430 'domain_blocklist' => '',
1431 'domain_blocklist_behavior' => 'silent',
1432 'domain_blocklist_message' => '',
1433 );
1434
1435 $settings = array_merge( $defaults, (array) get_option( 'f12-doi-settings', array() ) );
1436
1437 /**
1438 * Filter settings response so Pro can add its settings.
1439 *
1440 * @param array $settings The settings array.
1441 * @since 4.2.0
1442 */
1443 $settings = apply_filters( 'f12_doi_rest_settings_response', $settings );
1444
1445 return new \WP_REST_Response(
1446 array(
1447 'success' => true,
1448 'data' => $settings,
1449 ),
1450 200
1451 );
1452 }
1453
1454 public function updateSettings( \WP_REST_Request $request ): \WP_REST_Response {
1455 $input = $request->get_json_params();
1456 $settings = (array) get_option( 'f12-doi-settings', array() );
1457
1458 // Free settings validation & save
1459 $freeFields = array(
1460 'delete' => array(
1461 'type' => 'int',
1462 'min' => 0,
1463 'max' => 30,
1464 ),
1465 'delete_period' => array(
1466 'type' => 'enum',
1467 'values' => array( 'months', 'days', 'years' ),
1468 ),
1469 'delete_unconfirmed' => array(
1470 'type' => 'int',
1471 'min' => 0,
1472 'max' => 30,
1473 ),
1474 'delete_unconfirmed_period' => array(
1475 'type' => 'enum',
1476 'values' => array( 'months', 'days', 'years' ),
1477 ),
1478 'telemetry' => array(
1479 'type' => 'int',
1480 'min' => 0,
1481 'max' => 1,
1482 ),
1483 'credit_link' => array(
1484 'type' => 'int',
1485 'min' => 0,
1486 'max' => 1,
1487 ),
1488 'privacy_policy_page' => array(
1489 'type' => 'int',
1490 'min' => 0,
1491 ),
1492 'token_expiry_hours' => array(
1493 'type' => 'int',
1494 'min' => 0,
1495 'max' => 720,
1496 ),
1497 'rate_limit_ip' => array(
1498 'type' => 'int',
1499 'min' => 0,
1500 'max' => 100,
1501 ),
1502 'rate_limit_email' => array(
1503 'type' => 'int',
1504 'min' => 0,
1505 'max' => 100,
1506 ),
1507 'rate_limit_window' => array(
1508 'type' => 'int',
1509 'min' => 1,
1510 'max' => 1440,
1511 ),
1512 'keep_data_on_uninstall' => array(
1513 'type' => 'int',
1514 'min' => 0,
1515 'max' => 1,
1516 ),
1517 );
1518
1519 foreach ( $freeFields as $key => $rules ) {
1520 if ( ! array_key_exists( $key, $input ) ) {
1521 continue;
1522 }
1523
1524 $value = $input[ $key ];
1525
1526 switch ( $rules['type'] ) {
1527 case 'int':
1528 $value = (int) $value;
1529 if ( isset( $rules['min'] ) ) {
1530 $value = max( $rules['min'], $value ); }
1531 if ( isset( $rules['max'] ) ) {
1532 $value = min( $rules['max'], $value ); }
1533 break;
1534 case 'enum':
1535 $value = sanitize_text_field( $value );
1536 if ( ! in_array( $value, $rules['values'], true ) ) {
1537 $value = $rules['values'][0];
1538 }
1539 break;
1540 default:
1541 $value = sanitize_text_field( $value );
1542 }
1543
1544 $settings[ $key ] = $value;
1545 }
1546
1547 /**
1548 * Filter to allow Pro to process its settings before saving.
1549 *
1550 * @param array $settings The settings to save.
1551 * @param array $input The raw input from the request.
1552 * @since 4.2.0
1553 */
1554 $settings = apply_filters( 'f12_doi_rest_settings_save', $settings, $input );
1555
1556 update_option( 'f12-doi-settings', $settings );
1557
1558 AuditLogger::log( AuditLogger::TYPE_SETTINGS, AuditLogger::SEVERITY_INFO, __( 'Global settings updated via REST API.', 'double-opt-in' ) );
1559
1560 // Return updated settings
1561 $settings = apply_filters( 'f12_doi_rest_settings_response', $settings );
1562
1563 return new \WP_REST_Response(
1564 array(
1565 'success' => true,
1566 'data' => $settings,
1567 ),
1568 200
1569 );
1570 }
1571
1572 public function getPages( \WP_REST_Request $request ): \WP_REST_Response {
1573 $pages = $this->formService->getAvailablePages();
1574
1575 $list = array();
1576 foreach ( $pages as $id => $title ) {
1577 $list[] = array(
1578 'id' => $id,
1579 'title' => $title,
1580 );
1581 }
1582
1583 return new \WP_REST_Response(
1584 array(
1585 'success' => true,
1586 'data' => $list,
1587 ),
1588 200
1589 );
1590 }
1591
1592 public function getEmailTemplatesList( \WP_REST_Request $request ): \WP_REST_Response {
1593 $presets = $this->formService->getAvailableTemplates( 0 );
1594 $details = $this->formService->getTemplateDetails();
1595
1596 // Build a flat list for dropdown selectors
1597 $list = array();
1598 foreach ( $presets as $key => $label ) {
1599 $list[] = array(
1600 'id' => $key,
1601 'title' => $label,
1602 );
1603 }
1604 foreach ( $details as $key => $detail ) {
1605 $list[] = array(
1606 'id' => $key,
1607 'title' => $detail['title'] ?? $key,
1608 );
1609 }
1610
1611 return new \WP_REST_Response(
1612 array(
1613 'success' => true,
1614 'data' => $list,
1615 ),
1616 200
1617 );
1618 }
1619
1620 // ═══════════════════════════════════════════════════════════════
1621 // CATEGORIES
1622 // ═══════════════════════════════════════════════════════════════
1623
1624 public function getCategories( \WP_REST_Request $request ): \WP_REST_Response {
1625 global $wpdb;
1626
1627 $catTable = $wpdb->prefix . 'f12_cf7_doubleoptin_categories';
1628 $optinTable = $wpdb->prefix . 'f12_cf7_doubleoptin';
1629
1630 $categories = $wpdb->get_results(
1631 "SELECT c.*, COALESCE(o.cnt, 0) as optin_count
1632 FROM {$catTable} c
1633 LEFT JOIN (SELECT category, COUNT(*) as cnt FROM {$optinTable} GROUP BY category) o ON o.category = c.id
1634 ORDER BY c.name ASC",
1635 ARRAY_A
1636 );
1637
1638 return new \WP_REST_Response(
1639 array(
1640 'success' => true,
1641 'data' => $categories ?: array(),
1642 ),
1643 200
1644 );
1645 }
1646
1647 public function createCategory( \WP_REST_Request $request ): \WP_REST_Response {
1648 $data = $request->get_json_params();
1649 $name = sanitize_text_field( $data['name'] ?? '' );
1650
1651 if ( empty( $name ) ) {
1652 return new \WP_REST_Response(
1653 array(
1654 'success' => false,
1655 'message' => __( 'Category name is required.', 'double-opt-in' ),
1656 ),
1657 400
1658 );
1659 }
1660
1661 $category = new \forge12\contactform7\CF7DoubleOptIn\Category( \Forge12\Shared\Logger::getInstance() );
1662 $category->set_name( $name );
1663 $category->set_createtime( current_time( 'mysql' ) );
1664 $category->set_updatetime( current_time( 'mysql' ) );
1665 $id = $category->save();
1666
1667 if ( ! $id ) {
1668 return new \WP_REST_Response(
1669 array(
1670 'success' => false,
1671 'message' => __( 'Failed to create category.', 'double-opt-in' ),
1672 ),
1673 500
1674 );
1675 }
1676
1677 return new \WP_REST_Response(
1678 array(
1679 'success' => true,
1680 'data' => array(
1681 'id' => $id,
1682 'name' => $name,
1683 'createtime' => $category->get_createtime(),
1684 'updatetime' => $category->get_updatetime(),
1685 ),
1686 ),
1687 201
1688 );
1689 }
1690
1691 public function updateCategory( \WP_REST_Request $request ): \WP_REST_Response {
1692 $id = (int) $request->get_param( 'id' );
1693 $data = $request->get_json_params();
1694 $name = sanitize_text_field( $data['name'] ?? '' );
1695
1696 if ( empty( $name ) ) {
1697 return new \WP_REST_Response(
1698 array(
1699 'success' => false,
1700 'message' => __( 'Category name is required.', 'double-opt-in' ),
1701 ),
1702 400
1703 );
1704 }
1705
1706 $category = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( $id );
1707 if ( ! $category ) {
1708 return new \WP_REST_Response(
1709 array(
1710 'success' => false,
1711 'message' => __( 'Category not found.', 'double-opt-in' ),
1712 ),
1713 404
1714 );
1715 }
1716
1717 $category->set_name( $name );
1718 $category->set_updatetime( current_time( 'mysql' ) );
1719 $category->save();
1720
1721 return new \WP_REST_Response(
1722 array(
1723 'success' => true,
1724 'data' => array(
1725 'id' => $id,
1726 'name' => $name,
1727 'updatetime' => $category->get_updatetime(),
1728 ),
1729 ),
1730 200
1731 );
1732 }
1733
1734 public function deleteCategory( \WP_REST_Request $request ): \WP_REST_Response {
1735 $id = (int) $request->get_param( 'id' );
1736
1737 $result = \forge12\contactform7\CF7DoubleOptIn\Category::delete_by_id( $id );
1738
1739 // `false` = real DB error (query failed, legacy OptIn class missing).
1740 // `0` = no row matched the ID — typically a stale UI re-click on
1741 // an already-deleted category. Not an error: the desired
1742 // end-state (category not present) is reached.
1743 // `>= 1` = success.
1744 if ( $result === false ) {
1745 return new \WP_REST_Response(
1746 array(
1747 'success' => false,
1748 'message' => __( 'Failed to delete category.', 'double-opt-in' ),
1749 ),
1750 500
1751 );
1752 }
1753
1754 return new \WP_REST_Response(
1755 array(
1756 'success' => true,
1757 'message' => __( 'Category deleted.', 'double-opt-in' ),
1758 ),
1759 200
1760 );
1761 }
1762
1763 // ═══════════════════════════════════════════════════════════════
1764 // DATABASE
1765 // ═══════════════════════════════════════════════════════════════
1766
1767 public function getDatabaseStats( \WP_REST_Request $request ): \WP_REST_Response {
1768 global $wpdb;
1769 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
1770
1771 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
1772 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
1773 $unconfirmed = $total - $confirmed;
1774
1775 return new \WP_REST_Response(
1776 array(
1777 'success' => true,
1778 'data' => array(
1779 'total' => $total,
1780 'confirmed' => $confirmed,
1781 'unconfirmed' => $unconfirmed,
1782 ),
1783 ),
1784 200
1785 );
1786 }
1787
1788 public function cleanDatabase( \WP_REST_Request $request ): \WP_REST_Response {
1789 $data = $request->get_json_params();
1790 $scope = sanitize_text_field( $data['scope'] ?? '' );
1791
1792 if ( ! in_array( $scope, array( 'all', 'confirmed', 'unconfirmed' ), true ) ) {
1793 return new \WP_REST_Response(
1794 array(
1795 'success' => false,
1796 'message' => __( 'Invalid scope.', 'double-opt-in' ),
1797 ),
1798 400
1799 );
1800 }
1801
1802 $cleanUp = new \forge12\contactform7\CF7DoubleOptIn\CleanUp( $this->logger );
1803
1804 if ( $scope === 'all' || $scope === 'confirmed' ) {
1805 $cleanUp->removeConfirmedOptins( true );
1806 }
1807 if ( $scope === 'all' || $scope === 'unconfirmed' ) {
1808 $cleanUp->removeUnconfirmedOptins( true );
1809 }
1810
1811 AuditLogger::log(
1812 AuditLogger::TYPE_SETTINGS,
1813 AuditLogger::SEVERITY_WARNING,
1814 sprintf(
1815 __( 'Database cleaned (scope: %s).', 'double-opt-in' ),
1816 $scope
1817 )
1818 );
1819
1820 return new \WP_REST_Response(
1821 array(
1822 'success' => true,
1823 'message' => __( 'Database cleaned.', 'double-opt-in' ),
1824 ),
1825 200
1826 );
1827 }
1828
1829 public function resetDatabase( \WP_REST_Request $request ): \WP_REST_Response {
1830 $cleanUp = new \forge12\contactform7\CF7DoubleOptIn\CleanUp( $this->logger );
1831 $cleanUp->reset();
1832
1833 AuditLogger::log( AuditLogger::TYPE_SETTINGS, AuditLogger::SEVERITY_CRITICAL, __( 'Database reset performed.', 'double-opt-in' ) );
1834
1835 return new \WP_REST_Response(
1836 array(
1837 'success' => true,
1838 'message' => __( 'Database reset finished.', 'double-opt-in' ),
1839 ),
1840 200
1841 );
1842 }
1843
1844 // ═══════════════════════════════════════════════════════════════
1845 // AUDIT LOG
1846 // ═══════════════════════════════════════════════════════════════
1847
1848 public function getAuditEvents( \WP_REST_Request $request ): \WP_REST_Response {
1849 $result = AuditLogger::getEvents(
1850 array(
1851 'period' => (int) ( $request->get_param( 'period' ) ?: 30 ),
1852 'type' => $request->get_param( 'type' ) ?? '',
1853 'severity' => $request->get_param( 'severity' ) ?? '',
1854 'page' => (int) ( $request->get_param( 'page' ) ?: 1 ),
1855 'per_page' => (int) ( $request->get_param( 'per_page' ) ?: 15 ),
1856 )
1857 );
1858
1859 return new \WP_REST_Response(
1860 array(
1861 'success' => true,
1862 'data' => $result,
1863 ),
1864 200
1865 );
1866 }
1867
1868 public function getAuditSummary( \WP_REST_Request $request ): \WP_REST_Response {
1869 $period = (int) ( $request->get_param( 'period' ) ?: 30 );
1870 $summary = AuditLogger::getSummary( $period );
1871
1872 return new \WP_REST_Response(
1873 array(
1874 'success' => true,
1875 'data' => $summary,
1876 ),
1877 200
1878 );
1879 }
1880
1881 // ═══════════════════════════════════════════════════════════════
1882 // ADD-ON ROUTES
1883 // Core owns the route; the data comes from the add-on through a
1884 // filter. Without a handler the answer is ADDON_INACTIVE. Core
1885 // itself never checks a licence here (wordpress.org guideline 5):
1886 // the functionality lives in the add-on, which only hooks in when
1887 // it runs licensed.
1888 // ═══════════════════════════════════════════════════════════════
1889
1890 /**
1891 * Answer for a route whose add-on is not running.
1892 *
1893 * 404 with `code` so the SPA can tell it from an unknown route
1894 * (`rest_no_route`); `ApiError` reads `body.code`.
1895 */
1896 private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response {
1897 return new \WP_REST_Response(
1898 array(
1899 'success' => false,
1900 'code' => 'ADDON_INACTIVE',
1901 'addon' => $addonId,
1902 'message' => sprintf(
1903 /* translators: %s: add-on name */
1904 __( 'This feature is provided by the %s add-on. Install and activate the add-on with a valid license to use it.', 'double-opt-in' ),
1905 $addonName
1906 ),
1907 ),
1908 404
1909 );
1910 }
1911
1912 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1913 if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) {
1914 return $this->addonInactive( 'analytics', 'Analytics' );
1915 }
1916
1917 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1918
1919 return new \WP_REST_Response(
1920 array(
1921 'success' => true,
1922 'data' => $data,
1923 ),
1924 200
1925 );
1926 }
1927
1928 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1929 if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) {
1930 return $this->addonInactive( 'analytics', 'Analytics' );
1931 }
1932
1933 $formId = (int) $request->get_param( 'form_id' );
1934 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
1935
1936 return new \WP_REST_Response(
1937 array(
1938 'success' => true,
1939 'data' => $data,
1940 ),
1941 200
1942 );
1943 }
1944
1945 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1946 if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) {
1947 return $this->addonInactive( 'opt-out', 'Opt-Out' );
1948 }
1949
1950 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1951
1952 return new \WP_REST_Response(
1953 array(
1954 'success' => true,
1955 'data' => $data,
1956 ),
1957 200
1958 );
1959 }
1960
1961 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1962 if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) {
1963 return $this->addonInactive( 'opt-out', 'Opt-Out' );
1964 }
1965
1966 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
1967
1968 return new \WP_REST_Response(
1969 array(
1970 'success' => true,
1971 'data' => $data,
1972 ),
1973 200
1974 );
1975 }
1976
1977 /**
1978 * POST /f12-doi/v1/optout/page/generate
1979 *
1980 * One-click generator for the opt-out landing page. The logic lives in
1981 * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through
1982 * the filter below; Core only owns the route.
1983 *
1984 * @return \WP_REST_Response
1985 */
1986 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
1987 if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) {
1988 return $this->addonInactive( 'opt-out', 'Opt-Out' );
1989 }
1990
1991 /**
1992 * Filter: answer the opt-out page generator request.
1993 *
1994 * @param \WP_REST_Response|null $response Null until a handler answers.
1995 * @param \WP_REST_Request $request The request.
1996 *
1997 * @since 5.8.0
1998 */
1999 $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request );
2000
2001 return $response instanceof \WP_REST_Response
2002 ? $response
2003 : $this->addonInactive( 'opt-out', 'Opt-Out' );
2004 }
2005
2006 /**
2007 * License gate for the User Creation endpoints.
2008 *
2009 * Under bundle-only licensing the entitlement is expressed through the
2010 * addon's own registry lookup: the bundle grants `user-registration`
2011 * into AddonLicenseRegistry, and the addon hooks
2012 * `f12_doi_user_creation_authorized` to return `isLicensed('user-registration')`.
2013 * That is the precise, tier-safe gate — we do NOT fall back to the raw
2014 * `f12_doi_is_pro_active` bundle flag, which would over-authorize a
2015 * future bundle tier that does not cover this addon, or a site where the
2016 * addon plugin isn't even booted. (Per-module standalone licensing was
2017 * removed 2026-07-11 — see plan/bundle-only-licensing-migration.md.)
2018 */
2019 private function userCreationAuthorized(): bool {
2020 return (bool) apply_filters( 'f12_doi_user_creation_authorized', false );
2021 }
2022
2023 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2024 if ( ! $this->userCreationAuthorized() ) {
2025 return $this->addonInactive( 'user-registration', 'User Registration' );
2026 }
2027
2028 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2029
2030 return new \WP_REST_Response(
2031 array(
2032 'success' => true,
2033 'data' => $data,
2034 ),
2035 200
2036 );
2037 }
2038
2039 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2040 if ( ! $this->userCreationAuthorized() ) {
2041 return $this->addonInactive( 'user-registration', 'User Registration' );
2042 }
2043
2044 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2045
2046 return new \WP_REST_Response(
2047 array(
2048 'success' => true,
2049 'data' => $data,
2050 ),
2051 200
2052 );
2053 }
2054
2055 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2056 if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) {
2057 return $this->addonInactive( 'cleverreach', 'CleverReach' );
2058 }
2059
2060 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2061
2062 return new \WP_REST_Response(
2063 array(
2064 'success' => true,
2065 'data' => $data,
2066 ),
2067 200
2068 );
2069 }
2070
2071 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2072 if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) {
2073 return $this->addonInactive( 'cleverreach', 'CleverReach' );
2074 }
2075
2076 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2077
2078 return new \WP_REST_Response(
2079 array(
2080 'success' => true,
2081 'data' => $data,
2082 ),
2083 200
2084 );
2085 }
2086
2087 public function getLicense( \WP_REST_Request $request ): \WP_REST_Response {
2088 $data = array(
2089 'isActive' => apply_filters( 'f12_doi_is_pro_active', false ),
2090 'isInstalled' => defined( 'F12_DOI_PRO_VERSION' ),
2091 'licenseType' => null,
2092 'expiresAt' => null,
2093 'key' => null,
2094 'features' => $this->getFeaturesList(),
2095 );
2096
2097 /**
2098 * Filter license data so Pro can add real license info.
2099 *
2100 * @param array $data License data.
2101 * @since 4.2.0
2102 */
2103 $data = apply_filters( 'f12_doi_rest_license_response', $data );
2104
2105 return new \WP_REST_Response(
2106 array(
2107 'success' => true,
2108 'data' => $data,
2109 ),
2110 200
2111 );
2112 }
2113
2114 public function activateLicense( \WP_REST_Request $request ): \WP_REST_Response {
2115 $input = $request->get_json_params();
2116 $key = sanitize_text_field( $input['key'] ?? '' );
2117
2118 if ( empty( $key ) ) {
2119 return new \WP_REST_Response(
2120 array(
2121 'success' => false,
2122 'message' => __( 'License key is required.', 'double-opt-in' ),
2123 ),
2124 400
2125 );
2126 }
2127
2128 /**
2129 * Filter to let Pro handle license activation.
2130 *
2131 * @param array $result Result array.
2132 * @param string $key The license key.
2133 * @since 4.2.0
2134 */
2135 $result = apply_filters(
2136 'f12_doi_rest_license_activate',
2137 array(
2138 'success' => false,
2139 'message' => __( 'Pro plugin not installed.', 'double-opt-in' ),
2140 ),
2141 $key
2142 );
2143
2144 $status = ( $result['success'] ?? false ) ? 200 : 400;
2145
2146 return new \WP_REST_Response( $result, $status );
2147 }
2148
2149 public function deactivateLicense( \WP_REST_Request $request ): \WP_REST_Response {
2150 /**
2151 * Filter to let Pro handle license deactivation.
2152 *
2153 * @param array $result Result array.
2154 * @since 4.2.0
2155 */
2156 $result = apply_filters(
2157 'f12_doi_rest_license_deactivate',
2158 array(
2159 'success' => false,
2160 'message' => __( 'Pro plugin not installed.', 'double-opt-in' ),
2161 )
2162 );
2163
2164 $status = ( $result['success'] ?? false ) ? 200 : 400;
2165
2166 return new \WP_REST_Response( $result, $status );
2167 }
2168
2169 // ═══════════════════════════════════════════════════════════════
2170 // HELPERS
2171 // ═══════════════════════════════════════════════════════════════
2172
2173 /**
2174 * Format an opt-in database row for the API response.
2175 *
2176 * @param array $row The database row.
2177 * @param bool $detailed Whether to include full detail (content, mail data).
2178 *
2179 * @return array Formatted data.
2180 */
2181 private function formatOptinRow( array $row, bool $detailed = false ): array {
2182 $post = get_post( (int) $row['cf_form_id'] );
2183
2184 $data = array(
2185 'id' => (int) $row['id'],
2186 'hash' => $row['hash'],
2187 'email' => $row['email'],
2188 'formId' => (int) $row['cf_form_id'],
2189 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2190 'category' => (int) $row['category'],
2191 'confirmed' => (int) $row['doubleoptin'] === 1,
2192 // Confirmation mail: 'sent' (handed to the mail server), 'failed',
2193 // or '' (recorded before 5.8.0). Since 5.8.0.
2194 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2195 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2196 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2197 );
2198
2199 if ( $detailed ) {
2200 $data['ipRegister'] = $row['ipaddr_register'];
2201 $data['ipConfirmation'] = $row['ipaddr_confirmation'];
2202 $data['ipOptout'] = $row['ipaddr_optout'];
2203 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2204 $data['consentText'] = $row['consent_text'];
2205 $data['consentField'] = $row['consent_field'] ?? '';
2206 $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] );
2207 $data['mailError'] = (string) ( $row['mail_error'] ?? '' );
2208 $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) );
2209
2210 // Category name
2211 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2212 $data['categoryName'] = $cat ? $cat->get_name() : null;
2213
2214 // Parse content (form submission data)
2215 $content = maybe_unserialize( $row['content'] );
2216 $data['formData'] = is_array( $content ) ? $content : array();
2217
2218 // Consent acknowledgment proof: when a consent_field was
2219 // configured, look up the value the user actually submitted.
2220 // Truthy = explicit acknowledgment captured. Falsy = either
2221 // gate wasn't enforced or this is a legacy record.
2222 //
2223 // Where that value sits differs per integration, and this
2224 // reader got the list wrong twice:
2225 //
2226 // 2026-05-01 Avada wraps its fields under `data`, so the
2227 // flat lookup missed and every Avada opt-in
2228 // showed "User acknowledged: ✗ No" even with
2229 // the GDPR box explicitly checked.
2230 // 2026-08-27 Elementor stores the whole $_POST parameter
2231 // dict, so its fields sit under `form_fields`
2232 // — the same symptom, one integration further
2233 // on. The docblock added after the Avada fix
2234 // had predicted exactly this ("adding a third
2235 // shape would be the next addition").
2236 //
2237 // The shape list now lives in SubmittedContent, shared with
2238 // OptInFrontend::addPlaceholders() — the other consumer that
2239 // already knew all of them. A fourth integration with a
2240 // fourth layout is taught to both at once.
2241 //
2242 // The lookup also tolerates a consent_field that the
2243 // pre-5.3.2 sanitize_key() lowercased, so installations
2244 // recover from the update without re-saving every form.
2245 $data['consentAcknowledged'] = SubmittedContent::hasValue( $content, (string) $data['consentField'] );
2246
2247 // Parse mail_optin
2248 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2249 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
2250
2251 // Raw form HTML and mail HTML for detail view
2252 $data['formHtml'] = $row['form'] ?? '';
2253 $data['mailOptinHtml'] = is_string( $row['mail_optin'] ?? '' ) ? $row['mail_optin'] : '';
2254 }
2255
2256 return $data;
2257 }
2258
2259 /**
2260 * Convert a UTC datetime string from the DB to the site's
2261 * configured timezone (Settings → General → Timezone).
2262 *
2263 * The OptIn entity persists timestamps via gmdate(), so DB rows
2264 * always carry GMT/UTC. The admin React UI then displays whatever
2265 * the REST endpoint returns verbatim — so the conversion has to
2266 * happen here, server-side, against WP's site timezone (not the
2267 * browser locale: a German admin checking the panel from a NYC
2268 * hotel still wants to see Berlin time, because that's where the
2269 * site lives).
2270 *
2271 * Empty / null values pass through as ''. Malformed strings
2272 * (impossible in practice — the entity always emits Y-m-d H:i:s)
2273 * get returned unchanged via get_date_from_gmt's fallback.
2274 *
2275 * @param mixed $utcString Raw value from $row[...] — usually
2276 * 'YYYY-MM-DD HH:MM:SS' UTC, or empty.
2277 */
2278 private function toSiteLocalTime( $utcString ): string {
2279 if ( empty( $utcString ) ) {
2280 return '';
2281 }
2282 return get_date_from_gmt( (string) $utcString );
2283 }
2284
2285 /**
2286 * Get the features list for the license page.
2287 *
2288 * Built from three sources (in priority order):
2289 *
2290 * 1. Live addons in {@see AddonRegistry}. Each registered addon
2291 * contributes one entry using its own getId()/getName()/isAvailable().
2292 * This is the source of truth — Avada, Elementor, etc. show up
2293 * here as soon as their addon plugin is registered, with no
2294 * hardcoded names.
2295 *
2296 * 2. The `f12_doi_license_features` filter. Used by bundle-pro to
2297 * surface bundle-covered addons that are NOT yet installed (so
2298 * the user can see them as locked entries before running the
2299 * installer), and by other plugins that want to advertise an
2300 * unlock under the same license card. Filter contributions with
2301 * a slug that already came from the registry are ignored — the
2302 * live addon wins.
2303 *
2304 * Filter signature: array<int, array{name:string,slug:string,available:bool}>
2305 *
2306 * 3. Core-side non-addon perks (hardcoded below). These are
2307 * license-bound features that don't have their own AddonInterface
2308 * implementation — Priority Support, Multi-Column Email Layouts,
2309 * Social Icons, Conditional Content Blocks. Same dedup-by-slug
2310 * rule applies.
2311 *
2312 * @return array<int, array{name:string,slug:string,available:bool}>
2313 */
2314 private function getFeaturesList(): array {
2315 $isPro = (bool) apply_filters( 'f12_doi_is_pro_active', false );
2316
2317 $features = array();
2318
2319 // Tier 1 — live addons from the registry.
2320 if ( class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
2321 foreach ( AddonRegistry::getInstance()->all() as $id => $addon ) {
2322 $features[ $id ] = array(
2323 'name' => (string) $addon->getName(),
2324 'slug' => (string) $id,
2325 'available' => (bool) $addon->isAvailable(),
2326 );
2327 }
2328 }
2329
2330 // Tier 2 — third-party / bundle contributions.
2331 $contributions = apply_filters( 'f12_doi_license_features', array(), $isPro );
2332 if ( is_array( $contributions ) ) {
2333 foreach ( $contributions as $entry ) {
2334 if ( ! is_array( $entry ) ) {
2335 continue;
2336 }
2337 $slug = isset( $entry['slug'] ) ? (string) $entry['slug'] : '';
2338 if ( $slug === '' || isset( $features[ $slug ] ) ) {
2339 continue;
2340 }
2341 $features[ $slug ] = array(
2342 'name' => isset( $entry['name'] ) ? (string) $entry['name'] : $slug,
2343 'slug' => $slug,
2344 'available' => isset( $entry['available'] ) ? (bool) $entry['available'] : $isPro,
2345 );
2346 }
2347 }
2348
2349 // Tier 3 — Core-side non-addon Pro perks.
2350 $coreExtras = array(
2351 array(
2352 'name' => __( 'Multi-Column Email Layouts', 'double-opt-in' ),
2353 'slug' => 'multi-column',
2354 ),
2355 array(
2356 'name' => __( 'Social Icons in Emails', 'double-opt-in' ),
2357 'slug' => 'social-icons',
2358 ),
2359 array(
2360 'name' => __( 'Conditional Content Blocks', 'double-opt-in' ),
2361 'slug' => 'conditional-content',
2362 ),
2363 array(
2364 'name' => __( 'Priority Support', 'double-opt-in' ),
2365 'slug' => 'priority-support',
2366 ),
2367 );
2368 foreach ( $coreExtras as $entry ) {
2369 if ( isset( $features[ $entry['slug'] ] ) ) {
2370 continue;
2371 }
2372 $features[ $entry['slug'] ] = array(
2373 'name' => $entry['name'],
2374 'slug' => $entry['slug'],
2375 'available' => $isPro,
2376 );
2377 }
2378
2379 return array_values( $features );
2380 }
2381
2382 // ═══════════════════════════════════════════════════════════════
2383 // ADDONS MANIFEST (plan §9 — admin UI mount-point system)
2384 // ═══════════════════════════════════════════════════════════════
2385
2386 /**
2387 * GET /f12-doi/v1/addons
2388 *
2389 * Returns a manifest of every registered addon with:
2390 * - id, name, version, capabilities, available (from AddonInterface)
2391 * - ui.bundles[]: { handle, url } pairs of JS bundles Core should
2392 * dynamic-import() to unlock component registration
2393 * - ui.mountPoints: { mountPointId: [componentName, …] } — which
2394 * components each addon wants rendered at each mount point
2395 * - ui.sidebar[]: { title, url, icon } sidebar nav entries the
2396 * addon contributes. Pure data — Core renders. The entry
2397 * vanishes when the addon's WP plugin is deactivated because
2398 * the filter contribution disappears with it.
2399 *
2400 * Addons contribute their ui fragment via the filter
2401 * `f12_doi_admin_manifest_fragments`. Core merges the fragments
2402 * with auto-derived fields from AddonRegistry. An addon that
2403 * doesn't contribute anything still appears in the manifest (with
2404 * an empty ui section) so clients can display its status.
2405 *
2406 * Valid mount-point IDs (plan §9.2):
2407 * dashboard.widget, dashboard.alert, forms.integration-settings,
2408 * optins.row-action, optin.detail-panel, settings.tab,
2409 * license.section, addons.list
2410 *
2411 * @return \WP_REST_Response
2412 */
2413 public function getAddonsManifest( \WP_REST_Request $request ): \WP_REST_Response {
2414 $fragments = apply_filters( 'f12_doi_admin_manifest_fragments', array() );
2415 if ( ! is_array( $fragments ) ) {
2416 $fragments = array();
2417 }
2418
2419 $registered = array();
2420 if ( class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
2421 $registered = AddonRegistry::getInstance()->all();
2422 }
2423
2424 $addons = array();
2425
2426 // First pass: every registered addon gets an entry, even if
2427 // it contributes no UI. That lets the client show per-addon
2428 // licensing/boot state without a second round-trip.
2429 foreach ( $registered as $id => $addon ) {
2430 $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2431 $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2432 unset( $fragments[ $id ] );
2433 }
2434
2435 // Second pass: fragments for addons NOT in the registry
2436 // (rare — would be a plugin that hooks the filter without
2437 // using AddonInterface). Include them with minimal metadata
2438 // so the client still loads their bundle.
2439 foreach ( $fragments as $id => $fragment ) {
2440 if ( ! is_string( $id ) || ! is_array( $fragment ) ) {
2441 continue;
2442 }
2443 $addons[ $id ] = $this->buildAddonEntry( $id, null, $fragment );
2444 }
2445
2446 return new \WP_REST_Response(
2447 array(
2448 'addons' => array_values( $addons ),
2449 )
2450 );
2451 }
2452
2453 /**
2454 * Build one manifest entry from (optionally) the AddonInterface
2455 * instance plus the filter-contributed fragment.
2456 *
2457 * @param string $id
2458 * @param mixed $addon AddonInterface|null
2459 * @param array $fragment
2460 * @return array
2461 */
2462 private function buildAddonEntry( string $id, $addon, array $fragment ): array {
2463 $entry = array(
2464 'id' => $id,
2465 'name' => '',
2466 'version' => '',
2467 'capabilities' => array(),
2468 'available' => false,
2469 'ui' => array(
2470 'bundles' => array(),
2471 'mountPoints' => new \stdClass(),
2472 'sidebar' => array(),
2473 ),
2474 );
2475
2476 if ( $addon !== null && is_object( $addon ) ) {
2477 if ( method_exists( $addon, 'getName' ) ) {
2478 $entry['name'] = (string) $addon->getName();
2479 }
2480 if ( method_exists( $addon, 'getVersion' ) ) {
2481 $entry['version'] = (string) $addon->getVersion();
2482 }
2483 if ( method_exists( $addon, 'getCapabilities' ) ) {
2484 $caps = $addon->getCapabilities();
2485 if ( is_array( $caps ) ) {
2486 $entry['capabilities'] = array_values( array_map( 'strval', $caps ) );
2487 }
2488 }
2489 if ( method_exists( $addon, 'isAvailable' ) ) {
2490 try {
2491 $entry['available'] = (bool) $addon->isAvailable();
2492 } catch ( \Throwable $e ) {
2493 // Defensive — an addon throwing from isAvailable() is a bug
2494 // but shouldn't sink the whole manifest endpoint.
2495 $entry['available'] = false;
2496 }
2497 }
2498 }
2499
2500 // Fragment fields override the auto-derived values. Use this
2501 // sparingly — mostly to surface a nicer user-facing name or
2502 // to flag an addon "available" even when AddonInterface isn't
2503 // implemented.
2504 if ( isset( $fragment['name'] ) && is_string( $fragment['name'] ) ) {
2505 $entry['name'] = $fragment['name'];
2506 }
2507 if ( isset( $fragment['version'] ) && is_string( $fragment['version'] ) ) {
2508 $entry['version'] = $fragment['version'];
2509 }
2510 if ( isset( $fragment['capabilities'] ) && is_array( $fragment['capabilities'] ) ) {
2511 $entry['capabilities'] = array_values( array_map( 'strval', $fragment['capabilities'] ) );
2512 }
2513 if ( isset( $fragment['available'] ) ) {
2514 $entry['available'] = (bool) $fragment['available'];
2515 }
2516
2517 // UI section — sanitise bundles and mountPoints.
2518 if ( isset( $fragment['ui'] ) && is_array( $fragment['ui'] ) ) {
2519 $ui = $fragment['ui'];
2520
2521 if ( isset( $ui['bundles'] ) && is_array( $ui['bundles'] ) ) {
2522 $bundles = array();
2523 foreach ( $ui['bundles'] as $bundle ) {
2524 if ( ! is_array( $bundle ) ) {
2525 continue;
2526 }
2527 $handle = isset( $bundle['handle'] ) ? (string) $bundle['handle'] : '';
2528 $url = isset( $bundle['url'] ) ? (string) $bundle['url'] : '';
2529 if ( $handle === '' || $url === '' ) {
2530 continue;
2531 }
2532 $bundles[] = array(
2533 'handle' => $handle,
2534 'url' => esc_url_raw( $url ),
2535 );
2536 }
2537 $entry['ui']['bundles'] = $bundles;
2538 }
2539
2540 if ( isset( $ui['mountPoints'] ) && is_array( $ui['mountPoints'] ) ) {
2541 $mountPoints = array();
2542 foreach ( $ui['mountPoints'] as $mountId => $componentNames ) {
2543 if ( ! is_string( $mountId ) || ! is_array( $componentNames ) ) {
2544 continue;
2545 }
2546 $names = array();
2547 foreach ( $componentNames as $n ) {
2548 if ( is_string( $n ) && $n !== '' ) {
2549 $names[] = $n;
2550 }
2551 }
2552 if ( $names ) {
2553 $mountPoints[ $mountId ] = $names;
2554 }
2555 }
2556 $entry['ui']['mountPoints'] = $mountPoints ?: new \stdClass();
2557 }
2558
2559 // Sidebar nav contributions — pure data, no React component
2560 // involvement. Each entry: { title, url, icon }. The icon is
2561 // a lucide-react icon name (string); Core's sidebar maps it
2562 // to a component via an allowlist (unknown names fall back
2563 // to a generic icon). Lets addons add their own nav items
2564 // without owning any of Core's UI primitives, and lets
2565 // items disappear automatically when the addon's WP plugin
2566 // is deactivated (no fragment → no entry).
2567 if ( isset( $ui['sidebar'] ) && is_array( $ui['sidebar'] ) ) {
2568 $sidebar = array();
2569 foreach ( $ui['sidebar'] as $item ) {
2570 if ( ! is_array( $item ) ) {
2571 continue;
2572 }
2573 $title = isset( $item['title'] ) ? (string) $item['title'] : '';
2574 $url = isset( $item['url'] ) ? (string) $item['url'] : '';
2575 $icon = isset( $item['icon'] ) ? (string) $item['icon'] : '';
2576 if ( $title === '' || $url === '' ) {
2577 continue;
2578 }
2579 $sidebar[] = array(
2580 'title' => $title,
2581 'url' => $url,
2582 'icon' => $icon,
2583 );
2584 }
2585 $entry['ui']['sidebar'] = $sidebar;
2586 }
2587 }
2588
2589 return $entry;
2590 }
2591
2592 /**
2593 * GET /f12-doi/v1/addons/catalog
2594 *
2595 * Returns the canonical addon catalog with each entry's live state
2596 * merged in. Powers the marketplace-style Addons admin page:
2597 *
2598 * - For each catalog entry: is the plugin file present on disk
2599 * (`pluginFile` exists), is it active (`is_plugin_active`), and
2600 * does the registered AddonInterface report `isAvailable`?
2601 * - `status` collapses those three signals into one of
2602 * `active` / `inactive` / `not_installed` for easy CTA dispatch.
2603 * - `activateUrl` is a pre-signed wp-admin link for the plugin
2604 * activation flow when the plugin is on disk but inactive.
2605 *
2606 * Top-level fields:
2607 * `hasBundleLicense` — Pro license active. The page uses this to
2608 * decide between an "Install" CTA (for licensed users) and a
2609 * "Buy" CTA (for unlicensed users).
2610 *
2611 * @return \WP_REST_Response
2612 */
2613 public function getAddonCatalog( \WP_REST_Request $request ): \WP_REST_Response {
2614 if ( ! function_exists( 'is_plugin_active' ) ) {
2615 require_once ABSPATH . 'wp-admin/includes/plugin.php';
2616 }
2617
2618 $registered = array();
2619 if ( class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
2620 $registered = AddonRegistry::getInstance()->all();
2621 }
2622
2623 // License registry is optional — Core-only sites without bundle-pro
2624 // or any standalone-license addon may not have it bound. Resolved
2625 // once per request via the same Container the addons themselves use.
2626 $licenseRegistry = null;
2627 if (
2628 class_exists( '\\Forge12\\DoubleOptIn\\Container\\Container' )
2629 && interface_exists( '\\Forge12\\DoubleOptIn\\Licensing\\AddonLicenseRegistryInterface' )
2630 ) {
2631 try {
2632 $container = \Forge12\DoubleOptIn\Container\Container::getInstance();
2633 if ( $container->has( \Forge12\DoubleOptIn\Licensing\AddonLicenseRegistryInterface::class ) ) {
2634 $licenseRegistry = $container->get( \Forge12\DoubleOptIn\Licensing\AddonLicenseRegistryInterface::class );
2635 }
2636 } catch ( \Throwable $e ) {
2637 $licenseRegistry = null;
2638 }
2639 }
2640
2641 // Form integration registry — distinguishes "addon booted" (which
2642 // just means AvadaAddon::boot() ran) from "form integration is
2643 // actually wired" (which is what the Forms page consumes). The two
2644 // can diverge: AvadaAddon::boot() does its OWN second isAvailable()
2645 // check on the AvadaIntegration before calling registry->register().
2646 $formRegistry = null;
2647 if ( class_exists( '\\Forge12\\DoubleOptIn\\Integration\\FormIntegrationRegistry' ) ) {
2648 try {
2649 $formRegistry = \Forge12\DoubleOptIn\Integration\FormIntegrationRegistry::getInstance();
2650 } catch ( \Throwable $e ) {
2651 $formRegistry = null;
2652 }
2653 }
2654
2655 $entries = array();
2656 foreach ( \Forge12\DoubleOptIn\Addon\AddonCatalog::entries() as $id => $catalog ) {
2657 $pluginFile = $catalog['pluginFile'];
2658 $installed = file_exists( WP_PLUGIN_DIR . '/' . $pluginFile );
2659 $active = $installed && is_plugin_active( $pluginFile );
2660
2661 if ( $active ) {
2662 $status = 'active';
2663 } elseif ( $installed ) {
2664 $status = 'inactive';
2665 } else {
2666 $status = 'not_installed';
2667 }
2668
2669 $activateUrl = null;
2670 if ( $installed && ! $active ) {
2671 // Not wp_nonce_url(): it HTML-escapes & to &amp;, and this URL
2672 // goes as JSON into an href — "plugin" and "_wpnonce" then
2673 // arrived as "amp;plugin" and the activation failed.
2674 $activateUrl = add_query_arg(
2675 array(
2676 'action' => 'activate',
2677 'plugin' => rawurlencode( $pluginFile ),
2678 '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ),
2679 ),
2680 self_admin_url( 'plugins.php' )
2681 );
2682 }
2683
2684 $registeredAddon = $registered[ $id ] ?? null;
2685 $capabilities = array();
2686 if ( $registeredAddon !== null && method_exists( $registeredAddon, 'getCapabilities' ) ) {
2687 $caps = $registeredAddon->getCapabilities();
2688 if ( is_array( $caps ) ) {
2689 $capabilities = array_values( array_map( 'strval', $caps ) );
2690 }
2691 }
2692
2693 // ── Operational diagnostic ─────────────────────────────────
2694 // Distinguishes "WP plugin is active" from "addon is fully
2695 // booted and serving its features". The two diverge any time
2696 // the addon's isAvailable() returns false — usually because
2697 // of a missing license or a missing third-party prerequisite
2698 // (e.g. Avada is active in WP but Fusion Builder isn't).
2699 $registered_b = ( $registeredAddon !== null );
2700 $operational = false;
2701 $inactiveReason = null;
2702
2703 if ( $active ) {
2704 if ( ! $registered_b ) {
2705 // Plugin file activated but addon never reached the
2706 // registry — unusual; usually a fatal during boot.
2707 $inactiveReason = 'not_registered';
2708 } else {
2709 try {
2710 $operational = (bool) $registeredAddon->isAvailable();
2711 } catch ( \Throwable $e ) {
2712 $operational = false;
2713 }
2714
2715 if ( ! $operational ) {
2716 $isLicensed = false;
2717 if ( $licenseRegistry !== null ) {
2718 try {
2719 $isLicensed = (bool) $licenseRegistry->isLicensed( $id );
2720 } catch ( \Throwable $e ) {
2721 $isLicensed = false;
2722 }
2723 }
2724 // Bundle-only licensing: whether a covered module is
2725 // *unlocked* is a bundle-level fact, reported once via
2726 // `hasBundleLicense` below — never a per-addon reason.
2727 // The only genuinely per-addon reason a covered addon
2728 // stays non-operational is a missing third-party
2729 // prerequisite (e.g. Avada active but Fusion Builder
2730 // not). When it isn't licensed the bundle simply isn't
2731 // active; the UI surfaces that globally, not per card.
2732 $inactiveReason = $isLicensed ? 'prerequisite' : null;
2733 }
2734 }
2735 }
2736
2737 // ── Form integration diagnostic ───────────────────────────
2738 // Convention: form-providing addons use the same id for both
2739 // AddonInterface::getId() and FormIntegrationInterface::getIdentifier().
2740 // Non-form addons (analytics, reminder, …) won't have an entry
2741 // here; that's expected and we report null.
2742 $integrationRegistered = null;
2743 $integrationAvailable = null;
2744 $formCount = null;
2745
2746 if ( $formRegistry !== null && $formRegistry->has( $id ) ) {
2747 $integrationRegistered = true;
2748 $integration = $formRegistry->get( $id );
2749 if ( $integration !== null ) {
2750 try {
2751 $integrationAvailable = (bool) $integration->isAvailable();
2752 } catch ( \Throwable $e ) {
2753 $integrationAvailable = false;
2754 }
2755 if ( $integrationAvailable ) {
2756 try {
2757 $forms = $integration->getForms();
2758 $formCount = is_array( $forms ) ? count( $forms ) : 0;
2759 } catch ( \Throwable $e ) {
2760 $formCount = 0;
2761 }
2762 } else {
2763 $formCount = 0;
2764 }
2765 }
2766 } elseif ( $formRegistry !== null && $operational ) {
2767 // Addon booted but didn't register a form integration —
2768 // either it's a non-form addon, or AvadaAddon::boot() hit
2769 // its second isAvailable() guard and silently skipped
2770 // registration. We can't tell which from out here; the
2771 // UI can hint based on whether the addon's id is in a
2772 // known list of form integrations.
2773 $integrationRegistered = false;
2774 }
2775
2776 $entries[] = array(
2777 'id' => $id,
2778 'name' => (string) $catalog['name'],
2779 'description' => (string) $catalog['description'],
2780 'pluginFile' => $pluginFile,
2781 'bundleMember' => (bool) $catalog['bundleMember'],
2782 'status' => $status,
2783 'activateUrl' => $activateUrl,
2784 'capabilities' => $capabilities,
2785 'registered' => $registered_b,
2786 'operational' => $operational,
2787 'inactiveReason' => $inactiveReason,
2788 'integrationRegistered' => $integrationRegistered,
2789 'integrationAvailable' => $integrationAvailable,
2790 'formCount' => $formCount,
2791 );
2792 }
2793
2794 return new \WP_REST_Response(
2795 array(
2796 'entries' => $entries,
2797 'hasBundleLicense' => (bool) apply_filters( 'f12_doi_is_pro_active', false ),
2798 )
2799 );
2800 }
2801
2802 /**
2803 * POST /f12-doi/v1/addons/{id}/activate
2804 *
2805 * Activates the addon plugin file derived from `AddonCatalog`. The
2806 * standard REST X-WP-Nonce already authenticates the request — no
2807 * pre-signed wp-admin nonce URL needed.
2808 *
2809 * Returns 404 when the ID is unknown, 409 when the plugin file is
2810 * not on disk (caller must run the bundle installer first), or a
2811 * 500 with the WP_Error message if `activate_plugin` fails.
2812 *
2813 * @return \WP_REST_Response
2814 */
2815 public function activateAddon( \WP_REST_Request $request ): \WP_REST_Response {
2816 $id = (string) $request->get_param( 'id' );
2817 $catalog = \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id );
2818 if ( $catalog === null ) {
2819 return new \WP_REST_Response(
2820 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
2821 404
2822 );
2823 }
2824
2825 if ( ! function_exists( 'activate_plugin' ) ) {
2826 require_once ABSPATH . 'wp-admin/includes/plugin.php';
2827 }
2828
2829 $pluginFile = $catalog['pluginFile'];
2830
2831 if ( ! file_exists( WP_PLUGIN_DIR . '/' . $pluginFile ) ) {
2832 return new \WP_REST_Response(
2833 array(
2834 'message' => __( 'Addon is not installed. Install it first via the Pro bundle installer.', 'double-opt-in' ),
2835 ),
2836 409
2837 );
2838 }
2839
2840 $result = activate_plugin( $pluginFile );
2841 if ( is_wp_error( $result ) ) {
2842 return new \WP_REST_Response(
2843 array( 'message' => $result->get_error_message() ),
2844 500
2845 );
2846 }
2847
2848 return new \WP_REST_Response(
2849 array(
2850 'success' => true,
2851 'id' => $id,
2852 'status' => 'active',
2853 )
2854 );
2855 }
2856
2857 /**
2858 * GET /f12-doi/v1/addons/{id}/settings
2859 *
2860 * Returns the user-controlled settings for an addon (the feature
2861 * toggle and any addon-specific preferences). Distinct from the
2862 * WP-plugin activation state: a plugin can be active while its
2863 * feature is paused via this toggle.
2864 *
2865 * Default shape `{ enabled: true }` so addons that haven't been
2866 * configured yet behave like they're on — matches WP convention
2867 * where activating a plugin opts you in to its default behaviour.
2868 *
2869 * @return \WP_REST_Response
2870 */
2871 public function getAddonSettings( \WP_REST_Request $request ): \WP_REST_Response {
2872 $id = (string) $request->get_param( 'id' );
2873 if ( \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id ) === null ) {
2874 return new \WP_REST_Response(
2875 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
2876 404
2877 );
2878 }
2879
2880 $option = 'f12_doi_addon_' . $id . '_settings';
2881 $stored = get_option( $option, array() );
2882 $settings = is_array( $stored ) ? $stored : array();
2883
2884 return new \WP_REST_Response(
2885 array_merge( array( 'enabled' => true ), $settings )
2886 );
2887 }
2888
2889 /**
2890 * POST /f12-doi/v1/addons/{id}/settings
2891 *
2892 * Stores per-addon settings. Body must be a JSON object; only known
2893 * keys (currently `enabled`) are accepted. Future-proof: this is the
2894 * single endpoint addons grow into when they have more knobs than
2895 * just on/off.
2896 *
2897 * @return \WP_REST_Response
2898 */
2899 public function updateAddonSettings( \WP_REST_Request $request ): \WP_REST_Response {
2900 $id = (string) $request->get_param( 'id' );
2901 if ( \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id ) === null ) {
2902 return new \WP_REST_Response(
2903 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
2904 404
2905 );
2906 }
2907
2908 $body = $request->get_json_params();
2909 if ( ! is_array( $body ) ) {
2910 $body = array();
2911 }
2912
2913 $option = 'f12_doi_addon_' . $id . '_settings';
2914 $stored = get_option( $option, array() );
2915 if ( ! is_array( $stored ) ) {
2916 $stored = array();
2917 }
2918
2919 // Whitelist of keys an addon settings page may write. Each addon
2920 // can extend this via the `f12_doi_addon_settings_keys` filter as
2921 // it grows beyond a simple toggle.
2922 $allowedKeys = apply_filters(
2923 'f12_doi_addon_settings_keys',
2924 array( 'enabled' ),
2925 $id
2926 );
2927
2928 $next = $stored;
2929 foreach ( $body as $key => $value ) {
2930 if ( ! is_string( $key ) || ! in_array( $key, $allowedKeys, true ) ) {
2931 continue;
2932 }
2933 if ( $key === 'enabled' ) {
2934 $next['enabled'] = (bool) $value;
2935 continue;
2936 }
2937 $next[ $key ] = is_scalar( $value ) ? $value : null;
2938 }
2939
2940 /**
2941 * Final sanitize pass for addons whose settings carry nested
2942 * arrays (lists, objects). The scalar-only loop above can't
2943 * persist those — addons that need it hook this filter to
2944 * receive the raw body alongside the partially-built `$next`
2945 * and merge their structured fields back in. Reference impl:
2946 * see UniqueEmailAddon::sanitizeSettings (2026-05-13).
2947 *
2948 * @since 4.5.0
2949 *
2950 * @param array<string,mixed> $next Already-sanitised settings
2951 * so far (scalar fields).
2952 * @param array<string,mixed> $stored Previously-saved option.
2953 * @param string $addonId Internal addon ID.
2954 * @param array<string,mixed> $body Raw request body.
2955 */
2956 $next = apply_filters( 'f12_doi_addon_settings_sanitize', $next, $stored, $id, $body );
2957
2958 update_option( $option, $next, false );
2959
2960 // Also let addons hook a post-save signal to refresh caches etc.
2961 do_action( 'f12_doi_addon_settings_updated', $id, $next, $stored );
2962
2963 return new \WP_REST_Response(
2964 array_merge( array( 'enabled' => true ), $next )
2965 );
2966 }
2967
2968 /**
2969 * POST /f12-doi/v1/addons/{id}/deactivate
2970 *
2971 * Mirror of {@see activateAddon()}. Used by the Addons page to let
2972 * the user toggle an active addon off without uninstalling it.
2973 */
2974 public function deactivateAddon( \WP_REST_Request $request ): \WP_REST_Response {
2975 $id = (string) $request->get_param( 'id' );
2976 $catalog = \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id );
2977 if ( $catalog === null ) {
2978 return new \WP_REST_Response(
2979 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
2980 404
2981 );
2982 }
2983
2984 if ( ! function_exists( 'deactivate_plugins' ) ) {
2985 require_once ABSPATH . 'wp-admin/includes/plugin.php';
2986 }
2987
2988 deactivate_plugins( array( $catalog['pluginFile'] ) );
2989
2990 return new \WP_REST_Response(
2991 array(
2992 'success' => true,
2993 'id' => $id,
2994 'status' => 'inactive',
2995 )
2996 );
2997 }
2998 }
2999