PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.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 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / Admin / AdminRestController.php

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

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