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

3,188 lines 100.2 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 // Optional "Double Opt-In by Forge12" credit on the confirmation
1444 // page. Defaults to 0 and must stay that way: wordpress.org
1445 // guideline 10 requires credit links to be off unless the site
1446 // owner explicitly turns them on.
1447 'credit_link' => 0,
1448 'delete' => 12,
1449 'delete_unconfirmed' => 7,
1450 'delete_period' => 'months',
1451 'delete_unconfirmed_period' => 'months',
1452 'privacy_policy_page' => 0,
1453 'token_expiry_hours' => 48,
1454 'rate_limit_ip' => 5,
1455 'rate_limit_email' => 3,
1456 'rate_limit_window' => 60,
1457 // Preserve opt-in data when the plugin is deleted. Defaults to 1
1458 // (keep) so deleting the plugin never silently destroys GDPR
1459 // consent records; admins can opt into a full cleanup. Read by
1460 // uninstall.php.
1461 'keep_data_on_uninstall' => 1,
1462 // Pro defaults (will be overridden by f12_doi_rest_settings_response filter if Pro is active)
1463 'reminder_enabled' => 0,
1464 'reminder_delay' => 24,
1465 'reminder_subject' => '',
1466 'reminder_template' => '',
1467 'mx_validation_enabled' => 0,
1468 'mx_validation_behavior' => 'silent',
1469 'mx_validation_message' => '',
1470 'domain_blocklist_enabled' => 0,
1471 'domain_blocklist' => '',
1472 'domain_blocklist_behavior' => 'silent',
1473 'domain_blocklist_message' => '',
1474 );
1475
1476 $settings = array_merge( $defaults, (array) get_option( 'f12-doi-settings', array() ) );
1477
1478 /**
1479 * Filter settings response so Pro can add its settings.
1480 *
1481 * @param array $settings The settings array.
1482 * @since 4.2.0
1483 */
1484 $settings = apply_filters( 'f12_doi_rest_settings_response', $settings );
1485
1486 return new \WP_REST_Response(
1487 array(
1488 'success' => true,
1489 'data' => $settings,
1490 ),
1491 200
1492 );
1493 }
1494
1495 public function updateSettings( \WP_REST_Request $request ): \WP_REST_Response {
1496 $input = $request->get_json_params();
1497 $settings = (array) get_option( 'f12-doi-settings', array() );
1498
1499 // Free settings validation & save
1500 $freeFields = array(
1501 'delete' => array(
1502 'type' => 'int',
1503 'min' => 0,
1504 'max' => 30,
1505 ),
1506 'delete_period' => array(
1507 'type' => 'enum',
1508 'values' => array( 'months', 'days', 'years' ),
1509 ),
1510 'delete_unconfirmed' => array(
1511 'type' => 'int',
1512 'min' => 0,
1513 'max' => 30,
1514 ),
1515 'delete_unconfirmed_period' => array(
1516 'type' => 'enum',
1517 'values' => array( 'months', 'days', 'years' ),
1518 ),
1519 'telemetry' => array(
1520 'type' => 'int',
1521 'min' => 0,
1522 'max' => 1,
1523 ),
1524 'credit_link' => array(
1525 'type' => 'int',
1526 'min' => 0,
1527 'max' => 1,
1528 ),
1529 'privacy_policy_page' => array(
1530 'type' => 'int',
1531 'min' => 0,
1532 ),
1533 'token_expiry_hours' => array(
1534 'type' => 'int',
1535 'min' => 0,
1536 'max' => 720,
1537 ),
1538 'rate_limit_ip' => array(
1539 'type' => 'int',
1540 'min' => 0,
1541 'max' => 100,
1542 ),
1543 'rate_limit_email' => array(
1544 'type' => 'int',
1545 'min' => 0,
1546 'max' => 100,
1547 ),
1548 'rate_limit_window' => array(
1549 'type' => 'int',
1550 'min' => 1,
1551 'max' => 1440,
1552 ),
1553 'keep_data_on_uninstall' => array(
1554 'type' => 'int',
1555 'min' => 0,
1556 'max' => 1,
1557 ),
1558 );
1559
1560 foreach ( $freeFields as $key => $rules ) {
1561 if ( ! array_key_exists( $key, $input ) ) {
1562 continue;
1563 }
1564
1565 $value = $input[ $key ];
1566
1567 switch ( $rules['type'] ) {
1568 case 'int':
1569 $value = (int) $value;
1570 if ( isset( $rules['min'] ) ) {
1571 $value = max( $rules['min'], $value ); }
1572 if ( isset( $rules['max'] ) ) {
1573 $value = min( $rules['max'], $value ); }
1574 break;
1575 case 'enum':
1576 $value = sanitize_text_field( $value );
1577 if ( ! in_array( $value, $rules['values'], true ) ) {
1578 $value = $rules['values'][0];
1579 }
1580 break;
1581 default:
1582 $value = sanitize_text_field( $value );
1583 }
1584
1585 $settings[ $key ] = $value;
1586 }
1587
1588 /**
1589 * Filter to allow Pro to process its settings before saving.
1590 *
1591 * @param array $settings The settings to save.
1592 * @param array $input The raw input from the request.
1593 * @since 4.2.0
1594 */
1595 $settings = apply_filters( 'f12_doi_rest_settings_save', $settings, $input );
1596
1597 update_option( 'f12-doi-settings', $settings );
1598
1599 AuditLogger::log( AuditLogger::TYPE_SETTINGS, AuditLogger::SEVERITY_INFO, __( 'Global settings updated via REST API.', 'double-opt-in' ) );
1600
1601 // Return updated settings
1602 $settings = apply_filters( 'f12_doi_rest_settings_response', $settings );
1603
1604 return new \WP_REST_Response(
1605 array(
1606 'success' => true,
1607 'data' => $settings,
1608 ),
1609 200
1610 );
1611 }
1612
1613 public function getPages( \WP_REST_Request $request ): \WP_REST_Response {
1614 $pages = $this->formService->getAvailablePages();
1615
1616 $list = array();
1617 foreach ( $pages as $id => $title ) {
1618 $list[] = array(
1619 'id' => $id,
1620 'title' => $title,
1621 );
1622 }
1623
1624 return new \WP_REST_Response(
1625 array(
1626 'success' => true,
1627 'data' => $list,
1628 ),
1629 200
1630 );
1631 }
1632
1633 public function getEmailTemplatesList( \WP_REST_Request $request ): \WP_REST_Response {
1634 $presets = $this->formService->getAvailableTemplates( 0 );
1635 $details = $this->formService->getTemplateDetails();
1636
1637 // Build a flat list for dropdown selectors
1638 $list = array();
1639 foreach ( $presets as $key => $label ) {
1640 $list[] = array(
1641 'id' => $key,
1642 'title' => $label,
1643 );
1644 }
1645 foreach ( $details as $key => $detail ) {
1646 $list[] = array(
1647 'id' => $key,
1648 'title' => $detail['title'] ?? $key,
1649 );
1650 }
1651
1652 return new \WP_REST_Response(
1653 array(
1654 'success' => true,
1655 'data' => $list,
1656 ),
1657 200
1658 );
1659 }
1660
1661 // ═══════════════════════════════════════════════════════════════
1662 // CATEGORIES
1663 // ═══════════════════════════════════════════════════════════════
1664
1665 public function getCategories( \WP_REST_Request $request ): \WP_REST_Response {
1666 global $wpdb;
1667
1668 $catTable = $wpdb->prefix . 'f12_cf7_doubleoptin_categories';
1669 $optinTable = $wpdb->prefix . 'f12_cf7_doubleoptin';
1670
1671 $categories = $wpdb->get_results(
1672 "SELECT c.*, COALESCE(o.cnt, 0) as optin_count
1673 FROM {$catTable} c
1674 LEFT JOIN (SELECT category, COUNT(*) as cnt FROM {$optinTable} GROUP BY category) o ON o.category = c.id
1675 ORDER BY c.name ASC",
1676 ARRAY_A
1677 );
1678
1679 return new \WP_REST_Response(
1680 array(
1681 'success' => true,
1682 'data' => $categories ?: array(),
1683 ),
1684 200
1685 );
1686 }
1687
1688 public function createCategory( \WP_REST_Request $request ): \WP_REST_Response {
1689 $data = $request->get_json_params();
1690 $name = sanitize_text_field( $data['name'] ?? '' );
1691
1692 if ( empty( $name ) ) {
1693 return new \WP_REST_Response(
1694 array(
1695 'success' => false,
1696 'message' => __( 'Category name is required.', 'double-opt-in' ),
1697 ),
1698 400
1699 );
1700 }
1701
1702 $category = new \forge12\contactform7\CF7DoubleOptIn\Category( \Forge12\Shared\Logger::getInstance() );
1703 $category->set_name( $name );
1704 $category->set_createtime( current_time( 'mysql' ) );
1705 $category->set_updatetime( current_time( 'mysql' ) );
1706 $id = $category->save();
1707
1708 if ( ! $id ) {
1709 return new \WP_REST_Response(
1710 array(
1711 'success' => false,
1712 'message' => __( 'Failed to create category.', 'double-opt-in' ),
1713 ),
1714 500
1715 );
1716 }
1717
1718 return new \WP_REST_Response(
1719 array(
1720 'success' => true,
1721 'data' => array(
1722 'id' => $id,
1723 'name' => $name,
1724 'createtime' => $category->get_createtime(),
1725 'updatetime' => $category->get_updatetime(),
1726 ),
1727 ),
1728 201
1729 );
1730 }
1731
1732 public function updateCategory( \WP_REST_Request $request ): \WP_REST_Response {
1733 $id = (int) $request->get_param( 'id' );
1734 $data = $request->get_json_params();
1735 $name = sanitize_text_field( $data['name'] ?? '' );
1736
1737 if ( empty( $name ) ) {
1738 return new \WP_REST_Response(
1739 array(
1740 'success' => false,
1741 'message' => __( 'Category name is required.', 'double-opt-in' ),
1742 ),
1743 400
1744 );
1745 }
1746
1747 $category = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( $id );
1748 if ( ! $category ) {
1749 return new \WP_REST_Response(
1750 array(
1751 'success' => false,
1752 'message' => __( 'Category not found.', 'double-opt-in' ),
1753 ),
1754 404
1755 );
1756 }
1757
1758 $category->set_name( $name );
1759 $category->set_updatetime( current_time( 'mysql' ) );
1760 $category->save();
1761
1762 return new \WP_REST_Response(
1763 array(
1764 'success' => true,
1765 'data' => array(
1766 'id' => $id,
1767 'name' => $name,
1768 'updatetime' => $category->get_updatetime(),
1769 ),
1770 ),
1771 200
1772 );
1773 }
1774
1775 public function deleteCategory( \WP_REST_Request $request ): \WP_REST_Response {
1776 $id = (int) $request->get_param( 'id' );
1777
1778 $result = \forge12\contactform7\CF7DoubleOptIn\Category::delete_by_id( $id );
1779
1780 // `false` = real DB error (query failed, legacy OptIn class missing).
1781 // `0` = no row matched the ID — typically a stale UI re-click on
1782 // an already-deleted category. Not an error: the desired
1783 // end-state (category not present) is reached.
1784 // `>= 1` = success.
1785 if ( $result === false ) {
1786 return new \WP_REST_Response(
1787 array(
1788 'success' => false,
1789 'message' => __( 'Failed to delete category.', 'double-opt-in' ),
1790 ),
1791 500
1792 );
1793 }
1794
1795 return new \WP_REST_Response(
1796 array(
1797 'success' => true,
1798 'message' => __( 'Category deleted.', 'double-opt-in' ),
1799 ),
1800 200
1801 );
1802 }
1803
1804 // ═══════════════════════════════════════════════════════════════
1805 // DATABASE
1806 // ═══════════════════════════════════════════════════════════════
1807
1808 public function getDatabaseStats( \WP_REST_Request $request ): \WP_REST_Response {
1809 global $wpdb;
1810 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
1811
1812 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
1813 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
1814 $unconfirmed = $total - $confirmed;
1815
1816 return new \WP_REST_Response(
1817 array(
1818 'success' => true,
1819 'data' => array(
1820 'total' => $total,
1821 'confirmed' => $confirmed,
1822 'unconfirmed' => $unconfirmed,
1823 ),
1824 ),
1825 200
1826 );
1827 }
1828
1829 public function cleanDatabase( \WP_REST_Request $request ): \WP_REST_Response {
1830 $data = $request->get_json_params();
1831 $scope = sanitize_text_field( $data['scope'] ?? '' );
1832
1833 if ( ! in_array( $scope, array( 'all', 'confirmed', 'unconfirmed' ), true ) ) {
1834 return new \WP_REST_Response(
1835 array(
1836 'success' => false,
1837 'message' => __( 'Invalid scope.', 'double-opt-in' ),
1838 ),
1839 400
1840 );
1841 }
1842
1843 $cleanUp = new \forge12\contactform7\CF7DoubleOptIn\CleanUp( $this->logger );
1844
1845 if ( $scope === 'all' || $scope === 'confirmed' ) {
1846 $cleanUp->removeConfirmedOptins( true );
1847 }
1848 if ( $scope === 'all' || $scope === 'unconfirmed' ) {
1849 $cleanUp->removeUnconfirmedOptins( true );
1850 }
1851
1852 AuditLogger::log(
1853 AuditLogger::TYPE_SETTINGS,
1854 AuditLogger::SEVERITY_WARNING,
1855 sprintf(
1856 __( 'Database cleaned (scope: %s).', 'double-opt-in' ),
1857 $scope
1858 )
1859 );
1860
1861 return new \WP_REST_Response(
1862 array(
1863 'success' => true,
1864 'message' => __( 'Database cleaned.', 'double-opt-in' ),
1865 ),
1866 200
1867 );
1868 }
1869
1870 public function resetDatabase( \WP_REST_Request $request ): \WP_REST_Response {
1871 $cleanUp = new \forge12\contactform7\CF7DoubleOptIn\CleanUp( $this->logger );
1872 $cleanUp->reset();
1873
1874 AuditLogger::log( AuditLogger::TYPE_SETTINGS, AuditLogger::SEVERITY_CRITICAL, __( 'Database reset performed.', 'double-opt-in' ) );
1875
1876 return new \WP_REST_Response(
1877 array(
1878 'success' => true,
1879 'message' => __( 'Database reset finished.', 'double-opt-in' ),
1880 ),
1881 200
1882 );
1883 }
1884
1885 // ═══════════════════════════════════════════════════════════════
1886 // AUDIT LOG
1887 // ═══════════════════════════════════════════════════════════════
1888
1889 public function getAuditEvents( \WP_REST_Request $request ): \WP_REST_Response {
1890 $result = AuditLogger::getEvents(
1891 array(
1892 'period' => (int) ( $request->get_param( 'period' ) ?: 30 ),
1893 'type' => $request->get_param( 'type' ) ?? '',
1894 'severity' => $request->get_param( 'severity' ) ?? '',
1895 'page' => (int) ( $request->get_param( 'page' ) ?: 1 ),
1896 'per_page' => (int) ( $request->get_param( 'per_page' ) ?: 15 ),
1897 )
1898 );
1899
1900 return new \WP_REST_Response(
1901 array(
1902 'success' => true,
1903 'data' => $result,
1904 ),
1905 200
1906 );
1907 }
1908
1909 public function getAuditSummary( \WP_REST_Request $request ): \WP_REST_Response {
1910 $period = (int) ( $request->get_param( 'period' ) ?: 30 );
1911 $summary = AuditLogger::getSummary( $period );
1912
1913 return new \WP_REST_Response(
1914 array(
1915 'success' => true,
1916 'data' => $summary,
1917 ),
1918 200
1919 );
1920 }
1921
1922 // ═══════════════════════════════════════════════════════════════
1923 // PRO-EXTENSIBLE STUBS
1924 // These return minimal responses; Pro overrides via filters or
1925 // registers its own REST routes that take precedence.
1926 // ═══════════════════════════════════════════════════════════════
1927
1928 public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response {
1929 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1930 return new \WP_REST_Response(
1931 array(
1932 'success' => false,
1933 'message' => __( 'Pro version required.', 'double-opt-in' ),
1934 ),
1935 403
1936 );
1937 }
1938
1939 $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request );
1940
1941 return new \WP_REST_Response(
1942 array(
1943 'success' => true,
1944 'data' => $data,
1945 ),
1946 200
1947 );
1948 }
1949
1950 public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response {
1951 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1952 return new \WP_REST_Response(
1953 array(
1954 'success' => false,
1955 'message' => __( 'Pro version required.', 'double-opt-in' ),
1956 ),
1957 403
1958 );
1959 }
1960
1961 $formId = (int) $request->get_param( 'form_id' );
1962 $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request );
1963
1964 return new \WP_REST_Response(
1965 array(
1966 'success' => true,
1967 'data' => $data,
1968 ),
1969 200
1970 );
1971 }
1972
1973 public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1974 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1975 return new \WP_REST_Response(
1976 array(
1977 'success' => false,
1978 'message' => __( 'Pro version required.', 'double-opt-in' ),
1979 ),
1980 403
1981 );
1982 }
1983
1984 $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request );
1985
1986 return new \WP_REST_Response(
1987 array(
1988 'success' => true,
1989 'data' => $data,
1990 ),
1991 200
1992 );
1993 }
1994
1995 public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response {
1996 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
1997 return new \WP_REST_Response(
1998 array(
1999 'success' => false,
2000 'message' => __( 'Pro version required.', 'double-opt-in' ),
2001 ),
2002 403
2003 );
2004 }
2005
2006 $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request );
2007
2008 return new \WP_REST_Response(
2009 array(
2010 'success' => true,
2011 'data' => $data,
2012 ),
2013 200
2014 );
2015 }
2016
2017 /**
2018 * POST /f12-doi/v1/optout/page/generate
2019 *
2020 * One-click generator for the opt-out landing page. Eliminates the
2021 * onboarding-friction loop where the user has to manually create a
2022 * page and paste the shortcodes before opt-out works at all.
2023 *
2024 * Algorithm:
2025 * 1. Idempotent fast-path — scan `published` pages for the list
2026 * shortcode. If one already exists, return its ID untouched
2027 * (no duplicate creation, no content overwrite).
2028 * 2. Title-collision safety — if a page named "Opt-Out" exists
2029 * but WITHOUT the list shortcode, refuse to auto-modify. The
2030 * user might have intentionally repurposed that title; we'd
2031 * rather show a 409 with a clear message than clobber.
2032 * 3. Insert a fresh page with both shortcodes (form + list) so
2033 * the page is functional end-to-end out of the box.
2034 *
2035 * Response shape (always 200 unless error):
2036 * { page_id, page_title, edit_url, view_url, created: bool }
2037 *
2038 * @return \WP_REST_Response
2039 */
2040 public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response {
2041 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2042 return new \WP_REST_Response(
2043 array(
2044 'success' => false,
2045 'message' => __( 'Pro version required.', 'double-opt-in' ),
2046 ),
2047 403
2048 );
2049 }
2050
2051 if ( ! current_user_can( 'publish_pages' ) ) {
2052 return new \WP_REST_Response(
2053 array(
2054 'success' => false,
2055 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ),
2056 ),
2057 403
2058 );
2059 }
2060
2061 $listShortcode = '[f12-cf7-doubleoptin-optout-list]';
2062 $formShortcode = '[f12-cf7-doubleoptin-optout-form]';
2063
2064 // 1. Idempotent fast-path — first page with the list shortcode wins.
2065 $existing = get_posts(
2066 array(
2067 'post_type' => 'page',
2068 'post_status' => 'publish',
2069 'posts_per_page' => 1,
2070 's' => $listShortcode,
2071 'fields' => 'ids',
2072 'no_found_rows' => true,
2073 )
2074 );
2075 if ( ! empty( $existing ) ) {
2076 $pageId = (int) $existing[0];
2077 return new \WP_REST_Response(
2078 array(
2079 'success' => true,
2080 'created' => false,
2081 'page_id' => $pageId,
2082 'page_title' => get_the_title( $pageId ),
2083 'edit_url' => get_edit_post_link( $pageId, 'raw' ),
2084 'view_url' => get_permalink( $pageId ),
2085 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ),
2086 ),
2087 200
2088 );
2089 }
2090
2091 // 2. Title collision — a page literally titled "Opt-Out" but
2092 // without the shortcode is the user's own content. Refuse
2093 // to silently modify it.
2094 $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2095 $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2096 // Plain null check, not instanceof: this replaces `?->ID`, which only
2097 // short-circuits on null and does not care about the concrete class.
2098 $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2099 if ( $collisionId > 0 ) {
2100 return new \WP_REST_Response(
2101 array(
2102 'success' => false,
2103 'code' => 'TITLE_COLLISION',
2104 'page_id' => $collisionId,
2105 'edit_url' => get_edit_post_link( $collisionId, 'raw' ),
2106 'message' => sprintf(
2107 /* translators: %s = page title */
2108 __( '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' ),
2109 $desiredTitle
2110 ),
2111 ),
2112 409
2113 );
2114 }
2115
2116 // 3. Insert.
2117 $pageId = wp_insert_post(
2118 array(
2119 'post_type' => 'page',
2120 'post_status' => 'publish',
2121 'post_title' => $desiredTitle,
2122 'post_content' => $formShortcode . "\n\n" . $listShortcode,
2123 'post_author' => get_current_user_id(),
2124 'comment_status' => 'closed',
2125 'ping_status' => 'closed',
2126 ),
2127 true
2128 );
2129
2130 if ( is_wp_error( $pageId ) ) {
2131 return new \WP_REST_Response(
2132 array(
2133 'success' => false,
2134 'message' => $pageId->get_error_message(),
2135 ),
2136 500
2137 );
2138 }
2139
2140 return new \WP_REST_Response(
2141 array(
2142 'success' => true,
2143 'created' => true,
2144 'page_id' => (int) $pageId,
2145 'page_title' => $desiredTitle,
2146 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ),
2147 'view_url' => get_permalink( (int) $pageId ),
2148 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ),
2149 ),
2150 200
2151 );
2152 }
2153
2154 /**
2155 * License gate for the User Creation endpoints.
2156 *
2157 * Under bundle-only licensing the entitlement is expressed through the
2158 * addon's own registry lookup: the bundle grants `user-registration`
2159 * into AddonLicenseRegistry, and the addon hooks
2160 * `f12_doi_user_creation_authorized` to return `isLicensed('user-registration')`.
2161 * That is the precise, tier-safe gate — we do NOT fall back to the raw
2162 * `f12_doi_is_pro_active` bundle flag, which would over-authorize a
2163 * future bundle tier that does not cover this addon, or a site where the
2164 * addon plugin isn't even booted. (Per-module standalone licensing was
2165 * removed 2026-07-11 — see plan/bundle-only-licensing-migration.md.)
2166 */
2167 private function userCreationAuthorized(): bool {
2168 return (bool) apply_filters( 'f12_doi_user_creation_authorized', false );
2169 }
2170
2171 public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2172 if ( ! $this->userCreationAuthorized() ) {
2173 return new \WP_REST_Response(
2174 array(
2175 'success' => false,
2176 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2177 ),
2178 403
2179 );
2180 }
2181
2182 $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request );
2183
2184 return new \WP_REST_Response(
2185 array(
2186 'success' => true,
2187 'data' => $data,
2188 ),
2189 200
2190 );
2191 }
2192
2193 public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response {
2194 if ( ! $this->userCreationAuthorized() ) {
2195 return new \WP_REST_Response(
2196 array(
2197 'success' => false,
2198 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ),
2199 ),
2200 403
2201 );
2202 }
2203
2204 $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request );
2205
2206 return new \WP_REST_Response(
2207 array(
2208 'success' => true,
2209 'data' => $data,
2210 ),
2211 200
2212 );
2213 }
2214
2215 public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2216 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2217 return new \WP_REST_Response(
2218 array(
2219 'success' => false,
2220 'message' => __( 'Pro version required.', 'double-opt-in' ),
2221 ),
2222 403
2223 );
2224 }
2225
2226 $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request );
2227
2228 return new \WP_REST_Response(
2229 array(
2230 'success' => true,
2231 'data' => $data,
2232 ),
2233 200
2234 );
2235 }
2236
2237 public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response {
2238 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2239 return new \WP_REST_Response(
2240 array(
2241 'success' => false,
2242 'message' => __( 'Pro version required.', 'double-opt-in' ),
2243 ),
2244 403
2245 );
2246 }
2247
2248 $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request );
2249
2250 return new \WP_REST_Response(
2251 array(
2252 'success' => true,
2253 'data' => $data,
2254 ),
2255 200
2256 );
2257 }
2258
2259 public function getLicense( \WP_REST_Request $request ): \WP_REST_Response {
2260 $data = array(
2261 'isActive' => apply_filters( 'f12_doi_is_pro_active', false ),
2262 'isInstalled' => defined( 'F12_DOI_PRO_VERSION' ),
2263 'licenseType' => null,
2264 'expiresAt' => null,
2265 'key' => null,
2266 'features' => $this->getFeaturesList(),
2267 );
2268
2269 /**
2270 * Filter license data so Pro can add real license info.
2271 *
2272 * @param array $data License data.
2273 * @since 4.2.0
2274 */
2275 $data = apply_filters( 'f12_doi_rest_license_response', $data );
2276
2277 return new \WP_REST_Response(
2278 array(
2279 'success' => true,
2280 'data' => $data,
2281 ),
2282 200
2283 );
2284 }
2285
2286 public function activateLicense( \WP_REST_Request $request ): \WP_REST_Response {
2287 $input = $request->get_json_params();
2288 $key = sanitize_text_field( $input['key'] ?? '' );
2289
2290 if ( empty( $key ) ) {
2291 return new \WP_REST_Response(
2292 array(
2293 'success' => false,
2294 'message' => __( 'License key is required.', 'double-opt-in' ),
2295 ),
2296 400
2297 );
2298 }
2299
2300 /**
2301 * Filter to let Pro handle license activation.
2302 *
2303 * @param array $result Result array.
2304 * @param string $key The license key.
2305 * @since 4.2.0
2306 */
2307 $result = apply_filters(
2308 'f12_doi_rest_license_activate',
2309 array(
2310 'success' => false,
2311 'message' => __( 'Pro plugin not installed.', 'double-opt-in' ),
2312 ),
2313 $key
2314 );
2315
2316 $status = ( $result['success'] ?? false ) ? 200 : 400;
2317
2318 return new \WP_REST_Response( $result, $status );
2319 }
2320
2321 public function deactivateLicense( \WP_REST_Request $request ): \WP_REST_Response {
2322 /**
2323 * Filter to let Pro handle license deactivation.
2324 *
2325 * @param array $result Result array.
2326 * @since 4.2.0
2327 */
2328 $result = apply_filters(
2329 'f12_doi_rest_license_deactivate',
2330 array(
2331 'success' => false,
2332 'message' => __( 'Pro plugin not installed.', 'double-opt-in' ),
2333 )
2334 );
2335
2336 $status = ( $result['success'] ?? false ) ? 200 : 400;
2337
2338 return new \WP_REST_Response( $result, $status );
2339 }
2340
2341 public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response {
2342 if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) {
2343 return new \WP_REST_Response(
2344 array(
2345 'success' => false,
2346 'message' => __( 'Pro version required.', 'double-opt-in' ),
2347 ),
2348 403
2349 );
2350 }
2351
2352 $input = $request->get_json_params();
2353
2354 /**
2355 * Filter to let Pro handle database export.
2356 *
2357 * @param array $result Result.
2358 * @param array $input Export parameters.
2359 * @since 4.2.0
2360 */
2361 $result = apply_filters(
2362 'f12_doi_rest_database_export',
2363 array(
2364 'success' => false,
2365 'message' => __( 'Export not available.', 'double-opt-in' ),
2366 ),
2367 $input
2368 );
2369
2370 return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 );
2371 }
2372
2373 // ═══════════════════════════════════════════════════════════════
2374 // HELPERS
2375 // ═══════════════════════════════════════════════════════════════
2376
2377 /**
2378 * Format an opt-in database row for the API response.
2379 *
2380 * @param array $row The database row.
2381 * @param bool $detailed Whether to include full detail (content, mail data).
2382 *
2383 * @return array Formatted data.
2384 */
2385 private function formatOptinRow( array $row, bool $detailed = false ): array {
2386 $post = get_post( (int) $row['cf_form_id'] );
2387
2388 $data = array(
2389 'id' => (int) $row['id'],
2390 'hash' => $row['hash'],
2391 'email' => $row['email'],
2392 'formId' => (int) $row['cf_form_id'],
2393 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2394 'category' => (int) $row['category'],
2395 'confirmed' => (int) $row['doubleoptin'] === 1,
2396 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2397 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2398 );
2399
2400 if ( $detailed ) {
2401 $data['ipRegister'] = $row['ipaddr_register'];
2402 $data['ipConfirmation'] = $row['ipaddr_confirmation'];
2403 $data['ipOptout'] = $row['ipaddr_optout'];
2404 $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] );
2405 $data['consentText'] = $row['consent_text'];
2406 $data['consentField'] = $row['consent_field'] ?? '';
2407 $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] );
2408
2409 // Category name
2410 $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] );
2411 $data['categoryName'] = $cat ? $cat->get_name() : null;
2412
2413 // Parse content (form submission data)
2414 $content = maybe_unserialize( $row['content'] );
2415 $data['formData'] = is_array( $content ) ? $content : array();
2416
2417 // Consent acknowledgment proof: when a consent_field was
2418 // configured, look up the value the user actually submitted.
2419 // Truthy = explicit acknowledgment captured. Falsy = either
2420 // gate wasn't enforced or this is a legacy record.
2421 //
2422 // Storage shape varies per integration:
2423 // - CF7 / WPForms / GF (default path) store fields flat
2424 // at the top level: $content[fieldName] = value.
2425 // - Avada wraps fields under a `data` sub-key alongside
2426 // metadata (field_labels, field_types, form_parameter)
2427 // — its OnSubmit overrides the flat content set by
2428 // createOptIn(). For Avada records, $content[fieldName]
2429 // is undefined; the value lives at $content['data'][fieldName].
2430 //
2431 // Pre-2026-05-01 we only checked the flat shape, so every
2432 // Avada opt-in showed "User acknowledged: ✗ No" even when
2433 // the user explicitly checked the GDPR box. The fallback
2434 // below recognises the Avada shape too — adding a third
2435 // shape would be the next addition.
2436 $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2437 && is_array( $content )
2438 && (
2439 ! empty( $content[ $data['consentField'] ] )
2440 || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2441 );
2442
2443 // Parse mail_optin
2444 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2445 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();
2446
2447 // Raw form HTML and mail HTML for detail view
2448 $data['formHtml'] = $row['form'] ?? '';
2449 $data['mailOptinHtml'] = is_string( $row['mail_optin'] ?? '' ) ? $row['mail_optin'] : '';
2450 }
2451
2452 return $data;
2453 }
2454
2455 /**
2456 * Convert a UTC datetime string from the DB to the site's
2457 * configured timezone (Settings → General → Timezone).
2458 *
2459 * The OptIn entity persists timestamps via gmdate(), so DB rows
2460 * always carry GMT/UTC. The admin React UI then displays whatever
2461 * the REST endpoint returns verbatim — so the conversion has to
2462 * happen here, server-side, against WP's site timezone (not the
2463 * browser locale: a German admin checking the panel from a NYC
2464 * hotel still wants to see Berlin time, because that's where the
2465 * site lives).
2466 *
2467 * Empty / null values pass through as ''. Malformed strings
2468 * (impossible in practice — the entity always emits Y-m-d H:i:s)
2469 * get returned unchanged via get_date_from_gmt's fallback.
2470 *
2471 * @param mixed $utcString Raw value from $row[...] — usually
2472 * 'YYYY-MM-DD HH:MM:SS' UTC, or empty.
2473 */
2474 private function toSiteLocalTime( $utcString ): string {
2475 if ( empty( $utcString ) ) {
2476 return '';
2477 }
2478 return get_date_from_gmt( (string) $utcString );
2479 }
2480
2481 /**
2482 * Get the features list for the license page.
2483 *
2484 * Built from three sources (in priority order):
2485 *
2486 * 1. Live addons in {@see AddonRegistry}. Each registered addon
2487 * contributes one entry using its own getId()/getName()/isAvailable().
2488 * This is the source of truth — Avada, Elementor, etc. show up
2489 * here as soon as their addon plugin is registered, with no
2490 * hardcoded names.
2491 *
2492 * 2. The `f12_doi_license_features` filter. Used by bundle-pro to
2493 * surface bundle-covered addons that are NOT yet installed (so
2494 * the user can see them as locked entries before running the
2495 * installer), and by other plugins that want to advertise an
2496 * unlock under the same license card. Filter contributions with
2497 * a slug that already came from the registry are ignored — the
2498 * live addon wins.
2499 *
2500 * Filter signature: array<int, array{name:string,slug:string,available:bool}>
2501 *
2502 * 3. Core-side non-addon perks (hardcoded below). These are
2503 * license-bound features that don't have their own AddonInterface
2504 * implementation — Priority Support, Multi-Column Email Layouts,
2505 * Social Icons, Conditional Content Blocks. Same dedup-by-slug
2506 * rule applies.
2507 *
2508 * @return array<int, array{name:string,slug:string,available:bool}>
2509 */
2510 private function getFeaturesList(): array {
2511 $isPro = (bool) apply_filters( 'f12_doi_is_pro_active', false );
2512
2513 $features = array();
2514
2515 // Tier 1 — live addons from the registry.
2516 if ( class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
2517 foreach ( AddonRegistry::getInstance()->all() as $id => $addon ) {
2518 $features[ $id ] = array(
2519 'name' => (string) $addon->getName(),
2520 'slug' => (string) $id,
2521 'available' => (bool) $addon->isAvailable(),
2522 );
2523 }
2524 }
2525
2526 // Tier 2 — third-party / bundle contributions.
2527 $contributions = apply_filters( 'f12_doi_license_features', array(), $isPro );
2528 if ( is_array( $contributions ) ) {
2529 foreach ( $contributions as $entry ) {
2530 if ( ! is_array( $entry ) ) {
2531 continue;
2532 }
2533 $slug = isset( $entry['slug'] ) ? (string) $entry['slug'] : '';
2534 if ( $slug === '' || isset( $features[ $slug ] ) ) {
2535 continue;
2536 }
2537 $features[ $slug ] = array(
2538 'name' => isset( $entry['name'] ) ? (string) $entry['name'] : $slug,
2539 'slug' => $slug,
2540 'available' => isset( $entry['available'] ) ? (bool) $entry['available'] : $isPro,
2541 );
2542 }
2543 }
2544
2545 // Tier 3 — Core-side non-addon Pro perks.
2546 $coreExtras = array(
2547 array(
2548 'name' => __( 'Multi-Column Email Layouts', 'double-opt-in' ),
2549 'slug' => 'multi-column',
2550 ),
2551 array(
2552 'name' => __( 'Social Icons in Emails', 'double-opt-in' ),
2553 'slug' => 'social-icons',
2554 ),
2555 array(
2556 'name' => __( 'Conditional Content Blocks', 'double-opt-in' ),
2557 'slug' => 'conditional-content',
2558 ),
2559 array(
2560 'name' => __( 'Priority Support', 'double-opt-in' ),
2561 'slug' => 'priority-support',
2562 ),
2563 );
2564 foreach ( $coreExtras as $entry ) {
2565 if ( isset( $features[ $entry['slug'] ] ) ) {
2566 continue;
2567 }
2568 $features[ $entry['slug'] ] = array(
2569 'name' => $entry['name'],
2570 'slug' => $entry['slug'],
2571 'available' => $isPro,
2572 );
2573 }
2574
2575 return array_values( $features );
2576 }
2577
2578 // ═══════════════════════════════════════════════════════════════
2579 // ADDONS MANIFEST (plan §9 — admin UI mount-point system)
2580 // ═══════════════════════════════════════════════════════════════
2581
2582 /**
2583 * GET /f12-doi/v1/addons
2584 *
2585 * Returns a manifest of every registered addon with:
2586 * - id, name, version, capabilities, available (from AddonInterface)
2587 * - ui.bundles[]: { handle, url } pairs of JS bundles Core should
2588 * dynamic-import() to unlock component registration
2589 * - ui.mountPoints: { mountPointId: [componentName, …] } — which
2590 * components each addon wants rendered at each mount point
2591 * - ui.sidebar[]: { title, url, icon } sidebar nav entries the
2592 * addon contributes. Pure data — Core renders. The entry
2593 * vanishes when the addon's WP plugin is deactivated because
2594 * the filter contribution disappears with it.
2595 *
2596 * Addons contribute their ui fragment via the filter
2597 * `f12_doi_admin_manifest_fragments`. Core merges the fragments
2598 * with auto-derived fields from AddonRegistry. An addon that
2599 * doesn't contribute anything still appears in the manifest (with
2600 * an empty ui section) so clients can display its status.
2601 *
2602 * Valid mount-point IDs (plan §9.2):
2603 * dashboard.widget, dashboard.alert, forms.integration-settings,
2604 * optins.row-action, optin.detail-panel, settings.tab,
2605 * license.section, addons.list
2606 *
2607 * @return \WP_REST_Response
2608 */
2609 public function getAddonsManifest( \WP_REST_Request $request ): \WP_REST_Response {
2610 $fragments = apply_filters( 'f12_doi_admin_manifest_fragments', array() );
2611 if ( ! is_array( $fragments ) ) {
2612 $fragments = array();
2613 }
2614
2615 $registered = array();
2616 if ( class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
2617 $registered = AddonRegistry::getInstance()->all();
2618 }
2619
2620 $addons = array();
2621
2622 // First pass: every registered addon gets an entry, even if
2623 // it contributes no UI. That lets the client show per-addon
2624 // licensing/boot state without a second round-trip.
2625 foreach ( $registered as $id => $addon ) {
2626 $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array();
2627 $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment );
2628 unset( $fragments[ $id ] );
2629 }
2630
2631 // Second pass: fragments for addons NOT in the registry
2632 // (rare — would be a plugin that hooks the filter without
2633 // using AddonInterface). Include them with minimal metadata
2634 // so the client still loads their bundle.
2635 foreach ( $fragments as $id => $fragment ) {
2636 if ( ! is_string( $id ) || ! is_array( $fragment ) ) {
2637 continue;
2638 }
2639 $addons[ $id ] = $this->buildAddonEntry( $id, null, $fragment );
2640 }
2641
2642 return new \WP_REST_Response(
2643 array(
2644 'addons' => array_values( $addons ),
2645 )
2646 );
2647 }
2648
2649 /**
2650 * Build one manifest entry from (optionally) the AddonInterface
2651 * instance plus the filter-contributed fragment.
2652 *
2653 * @param string $id
2654 * @param mixed $addon AddonInterface|null
2655 * @param array $fragment
2656 * @return array
2657 */
2658 private function buildAddonEntry( string $id, $addon, array $fragment ): array {
2659 $entry = array(
2660 'id' => $id,
2661 'name' => '',
2662 'version' => '',
2663 'capabilities' => array(),
2664 'available' => false,
2665 'ui' => array(
2666 'bundles' => array(),
2667 'mountPoints' => new \stdClass(),
2668 'sidebar' => array(),
2669 ),
2670 );
2671
2672 if ( $addon !== null && is_object( $addon ) ) {
2673 if ( method_exists( $addon, 'getName' ) ) {
2674 $entry['name'] = (string) $addon->getName();
2675 }
2676 if ( method_exists( $addon, 'getVersion' ) ) {
2677 $entry['version'] = (string) $addon->getVersion();
2678 }
2679 if ( method_exists( $addon, 'getCapabilities' ) ) {
2680 $caps = $addon->getCapabilities();
2681 if ( is_array( $caps ) ) {
2682 $entry['capabilities'] = array_values( array_map( 'strval', $caps ) );
2683 }
2684 }
2685 if ( method_exists( $addon, 'isAvailable' ) ) {
2686 try {
2687 $entry['available'] = (bool) $addon->isAvailable();
2688 } catch ( \Throwable $e ) {
2689 // Defensive — an addon throwing from isAvailable() is a bug
2690 // but shouldn't sink the whole manifest endpoint.
2691 $entry['available'] = false;
2692 }
2693 }
2694 }
2695
2696 // Fragment fields override the auto-derived values. Use this
2697 // sparingly — mostly to surface a nicer user-facing name or
2698 // to flag an addon "available" even when AddonInterface isn't
2699 // implemented.
2700 if ( isset( $fragment['name'] ) && is_string( $fragment['name'] ) ) {
2701 $entry['name'] = $fragment['name'];
2702 }
2703 if ( isset( $fragment['version'] ) && is_string( $fragment['version'] ) ) {
2704 $entry['version'] = $fragment['version'];
2705 }
2706 if ( isset( $fragment['capabilities'] ) && is_array( $fragment['capabilities'] ) ) {
2707 $entry['capabilities'] = array_values( array_map( 'strval', $fragment['capabilities'] ) );
2708 }
2709 if ( isset( $fragment['available'] ) ) {
2710 $entry['available'] = (bool) $fragment['available'];
2711 }
2712
2713 // UI section — sanitise bundles and mountPoints.
2714 if ( isset( $fragment['ui'] ) && is_array( $fragment['ui'] ) ) {
2715 $ui = $fragment['ui'];
2716
2717 if ( isset( $ui['bundles'] ) && is_array( $ui['bundles'] ) ) {
2718 $bundles = array();
2719 foreach ( $ui['bundles'] as $bundle ) {
2720 if ( ! is_array( $bundle ) ) {
2721 continue;
2722 }
2723 $handle = isset( $bundle['handle'] ) ? (string) $bundle['handle'] : '';
2724 $url = isset( $bundle['url'] ) ? (string) $bundle['url'] : '';
2725 if ( $handle === '' || $url === '' ) {
2726 continue;
2727 }
2728 $bundles[] = array(
2729 'handle' => $handle,
2730 'url' => esc_url_raw( $url ),
2731 );
2732 }
2733 $entry['ui']['bundles'] = $bundles;
2734 }
2735
2736 if ( isset( $ui['mountPoints'] ) && is_array( $ui['mountPoints'] ) ) {
2737 $mountPoints = array();
2738 foreach ( $ui['mountPoints'] as $mountId => $componentNames ) {
2739 if ( ! is_string( $mountId ) || ! is_array( $componentNames ) ) {
2740 continue;
2741 }
2742 $names = array();
2743 foreach ( $componentNames as $n ) {
2744 if ( is_string( $n ) && $n !== '' ) {
2745 $names[] = $n;
2746 }
2747 }
2748 if ( $names ) {
2749 $mountPoints[ $mountId ] = $names;
2750 }
2751 }
2752 $entry['ui']['mountPoints'] = $mountPoints ?: new \stdClass();
2753 }
2754
2755 // Sidebar nav contributions — pure data, no React component
2756 // involvement. Each entry: { title, url, icon }. The icon is
2757 // a lucide-react icon name (string); Core's sidebar maps it
2758 // to a component via an allowlist (unknown names fall back
2759 // to a generic icon). Lets addons add their own nav items
2760 // without owning any of Core's UI primitives, and lets
2761 // items disappear automatically when the addon's WP plugin
2762 // is deactivated (no fragment → no entry).
2763 if ( isset( $ui['sidebar'] ) && is_array( $ui['sidebar'] ) ) {
2764 $sidebar = array();
2765 foreach ( $ui['sidebar'] as $item ) {
2766 if ( ! is_array( $item ) ) {
2767 continue;
2768 }
2769 $title = isset( $item['title'] ) ? (string) $item['title'] : '';
2770 $url = isset( $item['url'] ) ? (string) $item['url'] : '';
2771 $icon = isset( $item['icon'] ) ? (string) $item['icon'] : '';
2772 if ( $title === '' || $url === '' ) {
2773 continue;
2774 }
2775 $sidebar[] = array(
2776 'title' => $title,
2777 'url' => $url,
2778 'icon' => $icon,
2779 );
2780 }
2781 $entry['ui']['sidebar'] = $sidebar;
2782 }
2783 }
2784
2785 return $entry;
2786 }
2787
2788 /**
2789 * GET /f12-doi/v1/addons/catalog
2790 *
2791 * Returns the canonical addon catalog with each entry's live state
2792 * merged in. Powers the marketplace-style Addons admin page:
2793 *
2794 * - For each catalog entry: is the plugin file present on disk
2795 * (`pluginFile` exists), is it active (`is_plugin_active`), and
2796 * does the registered AddonInterface report `isAvailable`?
2797 * - `status` collapses those three signals into one of
2798 * `active` / `inactive` / `not_installed` for easy CTA dispatch.
2799 * - `activateUrl` is a pre-signed wp-admin link for the plugin
2800 * activation flow when the plugin is on disk but inactive.
2801 *
2802 * Top-level fields:
2803 * `hasBundleLicense` — Pro license active. The page uses this to
2804 * decide between an "Install" CTA (for licensed users) and a
2805 * "Buy" CTA (for unlicensed users).
2806 *
2807 * @return \WP_REST_Response
2808 */
2809 public function getAddonCatalog( \WP_REST_Request $request ): \WP_REST_Response {
2810 if ( ! function_exists( 'is_plugin_active' ) ) {
2811 require_once ABSPATH . 'wp-admin/includes/plugin.php';
2812 }
2813
2814 $registered = array();
2815 if ( class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
2816 $registered = AddonRegistry::getInstance()->all();
2817 }
2818
2819 // License registry is optional — Core-only sites without bundle-pro
2820 // or any standalone-license addon may not have it bound. Resolved
2821 // once per request via the same Container the addons themselves use.
2822 $licenseRegistry = null;
2823 if (
2824 class_exists( '\\Forge12\\DoubleOptIn\\Container\\Container' )
2825 && interface_exists( '\\Forge12\\DoubleOptIn\\Licensing\\AddonLicenseRegistryInterface' )
2826 ) {
2827 try {
2828 $container = \Forge12\DoubleOptIn\Container\Container::getInstance();
2829 if ( $container->has( \Forge12\DoubleOptIn\Licensing\AddonLicenseRegistryInterface::class ) ) {
2830 $licenseRegistry = $container->get( \Forge12\DoubleOptIn\Licensing\AddonLicenseRegistryInterface::class );
2831 }
2832 } catch ( \Throwable $e ) {
2833 $licenseRegistry = null;
2834 }
2835 }
2836
2837 // Form integration registry — distinguishes "addon booted" (which
2838 // just means AvadaAddon::boot() ran) from "form integration is
2839 // actually wired" (which is what the Forms page consumes). The two
2840 // can diverge: AvadaAddon::boot() does its OWN second isAvailable()
2841 // check on the AvadaIntegration before calling registry->register().
2842 $formRegistry = null;
2843 if ( class_exists( '\\Forge12\\DoubleOptIn\\Integration\\FormIntegrationRegistry' ) ) {
2844 try {
2845 $formRegistry = \Forge12\DoubleOptIn\Integration\FormIntegrationRegistry::getInstance();
2846 } catch ( \Throwable $e ) {
2847 $formRegistry = null;
2848 }
2849 }
2850
2851 $entries = array();
2852 foreach ( \Forge12\DoubleOptIn\Addon\AddonCatalog::entries() as $id => $catalog ) {
2853 $pluginFile = $catalog['pluginFile'];
2854 $installed = file_exists( WP_PLUGIN_DIR . '/' . $pluginFile );
2855 $active = $installed && is_plugin_active( $pluginFile );
2856
2857 if ( $active ) {
2858 $status = 'active';
2859 } elseif ( $installed ) {
2860 $status = 'inactive';
2861 } else {
2862 $status = 'not_installed';
2863 }
2864
2865 $activateUrl = null;
2866 if ( $installed && ! $active ) {
2867 $activateUrl = wp_nonce_url(
2868 self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ),
2869 'activate-plugin_' . $pluginFile
2870 );
2871 }
2872
2873 $registeredAddon = $registered[ $id ] ?? null;
2874 $capabilities = array();
2875 if ( $registeredAddon !== null && method_exists( $registeredAddon, 'getCapabilities' ) ) {
2876 $caps = $registeredAddon->getCapabilities();
2877 if ( is_array( $caps ) ) {
2878 $capabilities = array_values( array_map( 'strval', $caps ) );
2879 }
2880 }
2881
2882 // ── Operational diagnostic ─────────────────────────────────
2883 // Distinguishes "WP plugin is active" from "addon is fully
2884 // booted and serving its features". The two diverge any time
2885 // the addon's isAvailable() returns false — usually because
2886 // of a missing license or a missing third-party prerequisite
2887 // (e.g. Avada is active in WP but Fusion Builder isn't).
2888 $registered_b = ( $registeredAddon !== null );
2889 $operational = false;
2890 $inactiveReason = null;
2891
2892 if ( $active ) {
2893 if ( ! $registered_b ) {
2894 // Plugin file activated but addon never reached the
2895 // registry — unusual; usually a fatal during boot.
2896 $inactiveReason = 'not_registered';
2897 } else {
2898 try {
2899 $operational = (bool) $registeredAddon->isAvailable();
2900 } catch ( \Throwable $e ) {
2901 $operational = false;
2902 }
2903
2904 if ( ! $operational ) {
2905 $isLicensed = false;
2906 if ( $licenseRegistry !== null ) {
2907 try {
2908 $isLicensed = (bool) $licenseRegistry->isLicensed( $id );
2909 } catch ( \Throwable $e ) {
2910 $isLicensed = false;
2911 }
2912 }
2913 // Bundle-only licensing: whether a covered module is
2914 // *unlocked* is a bundle-level fact, reported once via
2915 // `hasBundleLicense` below — never a per-addon reason.
2916 // The only genuinely per-addon reason a covered addon
2917 // stays non-operational is a missing third-party
2918 // prerequisite (e.g. Avada active but Fusion Builder
2919 // not). When it isn't licensed the bundle simply isn't
2920 // active; the UI surfaces that globally, not per card.
2921 $inactiveReason = $isLicensed ? 'prerequisite' : null;
2922 }
2923 }
2924 }
2925
2926 // ── Form integration diagnostic ───────────────────────────
2927 // Convention: form-providing addons use the same id for both
2928 // AddonInterface::getId() and FormIntegrationInterface::getIdentifier().
2929 // Non-form addons (analytics, reminder, …) won't have an entry
2930 // here; that's expected and we report null.
2931 $integrationRegistered = null;
2932 $integrationAvailable = null;
2933 $formCount = null;
2934
2935 if ( $formRegistry !== null && $formRegistry->has( $id ) ) {
2936 $integrationRegistered = true;
2937 $integration = $formRegistry->get( $id );
2938 if ( $integration !== null ) {
2939 try {
2940 $integrationAvailable = (bool) $integration->isAvailable();
2941 } catch ( \Throwable $e ) {
2942 $integrationAvailable = false;
2943 }
2944 if ( $integrationAvailable ) {
2945 try {
2946 $forms = $integration->getForms();
2947 $formCount = is_array( $forms ) ? count( $forms ) : 0;
2948 } catch ( \Throwable $e ) {
2949 $formCount = 0;
2950 }
2951 } else {
2952 $formCount = 0;
2953 }
2954 }
2955 } elseif ( $formRegistry !== null && $operational ) {
2956 // Addon booted but didn't register a form integration —
2957 // either it's a non-form addon, or AvadaAddon::boot() hit
2958 // its second isAvailable() guard and silently skipped
2959 // registration. We can't tell which from out here; the
2960 // UI can hint based on whether the addon's id is in a
2961 // known list of form integrations.
2962 $integrationRegistered = false;
2963 }
2964
2965 $entries[] = array(
2966 'id' => $id,
2967 'name' => (string) $catalog['name'],
2968 'description' => (string) $catalog['description'],
2969 'pluginFile' => $pluginFile,
2970 'bundleMember' => (bool) $catalog['bundleMember'],
2971 'status' => $status,
2972 'activateUrl' => $activateUrl,
2973 'capabilities' => $capabilities,
2974 'registered' => $registered_b,
2975 'operational' => $operational,
2976 'inactiveReason' => $inactiveReason,
2977 'integrationRegistered' => $integrationRegistered,
2978 'integrationAvailable' => $integrationAvailable,
2979 'formCount' => $formCount,
2980 );
2981 }
2982
2983 return new \WP_REST_Response(
2984 array(
2985 'entries' => $entries,
2986 'hasBundleLicense' => (bool) apply_filters( 'f12_doi_is_pro_active', false ),
2987 )
2988 );
2989 }
2990
2991 /**
2992 * POST /f12-doi/v1/addons/{id}/activate
2993 *
2994 * Activates the addon plugin file derived from `AddonCatalog`. The
2995 * standard REST X-WP-Nonce already authenticates the request — no
2996 * pre-signed wp-admin nonce URL needed.
2997 *
2998 * Returns 404 when the ID is unknown, 409 when the plugin file is
2999 * not on disk (caller must run the bundle installer first), or a
3000 * 500 with the WP_Error message if `activate_plugin` fails.
3001 *
3002 * @return \WP_REST_Response
3003 */
3004 public function activateAddon( \WP_REST_Request $request ): \WP_REST_Response {
3005 $id = (string) $request->get_param( 'id' );
3006 $catalog = \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id );
3007 if ( $catalog === null ) {
3008 return new \WP_REST_Response(
3009 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
3010 404
3011 );
3012 }
3013
3014 if ( ! function_exists( 'activate_plugin' ) ) {
3015 require_once ABSPATH . 'wp-admin/includes/plugin.php';
3016 }
3017
3018 $pluginFile = $catalog['pluginFile'];
3019
3020 if ( ! file_exists( WP_PLUGIN_DIR . '/' . $pluginFile ) ) {
3021 return new \WP_REST_Response(
3022 array(
3023 'message' => __( 'Addon is not installed. Install it first via the Pro bundle installer.', 'double-opt-in' ),
3024 ),
3025 409
3026 );
3027 }
3028
3029 $result = activate_plugin( $pluginFile );
3030 if ( is_wp_error( $result ) ) {
3031 return new \WP_REST_Response(
3032 array( 'message' => $result->get_error_message() ),
3033 500
3034 );
3035 }
3036
3037 return new \WP_REST_Response(
3038 array(
3039 'success' => true,
3040 'id' => $id,
3041 'status' => 'active',
3042 )
3043 );
3044 }
3045
3046 /**
3047 * GET /f12-doi/v1/addons/{id}/settings
3048 *
3049 * Returns the user-controlled settings for an addon (the feature
3050 * toggle and any addon-specific preferences). Distinct from the
3051 * WP-plugin activation state: a plugin can be active while its
3052 * feature is paused via this toggle.
3053 *
3054 * Default shape `{ enabled: true }` so addons that haven't been
3055 * configured yet behave like they're on — matches WP convention
3056 * where activating a plugin opts you in to its default behaviour.
3057 *
3058 * @return \WP_REST_Response
3059 */
3060 public function getAddonSettings( \WP_REST_Request $request ): \WP_REST_Response {
3061 $id = (string) $request->get_param( 'id' );
3062 if ( \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id ) === null ) {
3063 return new \WP_REST_Response(
3064 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
3065 404
3066 );
3067 }
3068
3069 $option = 'f12_doi_addon_' . $id . '_settings';
3070 $stored = get_option( $option, array() );
3071 $settings = is_array( $stored ) ? $stored : array();
3072
3073 return new \WP_REST_Response(
3074 array_merge( array( 'enabled' => true ), $settings )
3075 );
3076 }
3077
3078 /**
3079 * POST /f12-doi/v1/addons/{id}/settings
3080 *
3081 * Stores per-addon settings. Body must be a JSON object; only known
3082 * keys (currently `enabled`) are accepted. Future-proof: this is the
3083 * single endpoint addons grow into when they have more knobs than
3084 * just on/off.
3085 *
3086 * @return \WP_REST_Response
3087 */
3088 public function updateAddonSettings( \WP_REST_Request $request ): \WP_REST_Response {
3089 $id = (string) $request->get_param( 'id' );
3090 if ( \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id ) === null ) {
3091 return new \WP_REST_Response(
3092 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
3093 404
3094 );
3095 }
3096
3097 $body = $request->get_json_params();
3098 if ( ! is_array( $body ) ) {
3099 $body = array();
3100 }
3101
3102 $option = 'f12_doi_addon_' . $id . '_settings';
3103 $stored = get_option( $option, array() );
3104 if ( ! is_array( $stored ) ) {
3105 $stored = array();
3106 }
3107
3108 // Whitelist of keys an addon settings page may write. Each addon
3109 // can extend this via the `f12_doi_addon_settings_keys` filter as
3110 // it grows beyond a simple toggle.
3111 $allowedKeys = apply_filters(
3112 'f12_doi_addon_settings_keys',
3113 array( 'enabled' ),
3114 $id
3115 );
3116
3117 $next = $stored;
3118 foreach ( $body as $key => $value ) {
3119 if ( ! is_string( $key ) || ! in_array( $key, $allowedKeys, true ) ) {
3120 continue;
3121 }
3122 if ( $key === 'enabled' ) {
3123 $next['enabled'] = (bool) $value;
3124 continue;
3125 }
3126 $next[ $key ] = is_scalar( $value ) ? $value : null;
3127 }
3128
3129 /**
3130 * Final sanitize pass for addons whose settings carry nested
3131 * arrays (lists, objects). The scalar-only loop above can't
3132 * persist those — addons that need it hook this filter to
3133 * receive the raw body alongside the partially-built `$next`
3134 * and merge their structured fields back in. Reference impl:
3135 * see UniqueEmailAddon::sanitizeSettings (2026-05-13).
3136 *
3137 * @since 4.5.0
3138 *
3139 * @param array<string,mixed> $next Already-sanitised settings
3140 * so far (scalar fields).
3141 * @param array<string,mixed> $stored Previously-saved option.
3142 * @param string $addonId Internal addon ID.
3143 * @param array<string,mixed> $body Raw request body.
3144 */
3145 $next = apply_filters( 'f12_doi_addon_settings_sanitize', $next, $stored, $id, $body );
3146
3147 update_option( $option, $next, false );
3148
3149 // Also let addons hook a post-save signal to refresh caches etc.
3150 do_action( 'f12_doi_addon_settings_updated', $id, $next, $stored );
3151
3152 return new \WP_REST_Response(
3153 array_merge( array( 'enabled' => true ), $next )
3154 );
3155 }
3156
3157 /**
3158 * POST /f12-doi/v1/addons/{id}/deactivate
3159 *
3160 * Mirror of {@see activateAddon()}. Used by the Addons page to let
3161 * the user toggle an active addon off without uninstalling it.
3162 */
3163 public function deactivateAddon( \WP_REST_Request $request ): \WP_REST_Response {
3164 $id = (string) $request->get_param( 'id' );
3165 $catalog = \Forge12\DoubleOptIn\Addon\AddonCatalog::get( $id );
3166 if ( $catalog === null ) {
3167 return new \WP_REST_Response(
3168 array( 'message' => __( 'Unknown addon.', 'double-opt-in' ) ),
3169 404
3170 );
3171 }
3172
3173 if ( ! function_exists( 'deactivate_plugins' ) ) {
3174 require_once ABSPATH . 'wp-admin/includes/plugin.php';
3175 }
3176
3177 deactivate_plugins( array( $catalog['pluginFile'] ) );
3178
3179 return new \WP_REST_Response(
3180 array(
3181 'success' => true,
3182 'id' => $id,
3183 'status' => 'inactive',
3184 )
3185 );
3186 }
3187 }
3188