PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.13.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.13.0
5.12.0 5.13.0 5.13.1 5.11.0 5.10.0 5.9.0 5.8.0 5.8.1 5.7.0 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 All 47 releases
double-opt-in / src / Admin / AdminRestController.php

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

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