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

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

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