PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.0
5.9.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 All 42 releases
double-opt-in / src / Admin / AdminRestController.php

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

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