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

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

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