PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.1.5
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.1.5
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.1.5, at src/Admin/AdminRestController.php

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