PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.0
5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / FormSettings / FormSettingsService.php

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

491 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Form Settings Service
4 *
5 * @package Forge12\DoubleOptIn\FormSettings
6 * @since 4.1.0
7 */
8
9 namespace Forge12\DoubleOptIn\FormSettings;
10
11 use Forge12\DoubleOptIn\EmailTemplates\EmailTemplateRepository;
12 use Forge12\DoubleOptIn\Health\StaleConsentFieldCheck;
13 use Forge12\DoubleOptIn\Integration\FormIntegrationRegistry;
14 use Forge12\DoubleOptIn\Integration\SubmittedContent;
15 use Forge12\Shared\LoggerInterface;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Class FormSettingsService
23 *
24 * Centralized service for managing form settings across all integrations.
25 */
26 class FormSettingsService {
27
28 /**
29 * Post-meta key under which DOI form settings are stored.
30 *
31 * Public so cross-cutting consumers (data-cleanup migrations such
32 * as {@see \Forge12\DoubleOptIn\Migration\MigrationFormCompletenessSweep},
33 * audit tools) can reference the same storage key without
34 * hardcoding the string. The value itself is load-bearing across
35 * the addon ecosystem — changing it would be a separate
36 * data-migration project.
37 *
38 * @var string
39 */
40 public const META_KEY = 'f12-cf7-doubleoptin';
41
42 /**
43 * Logger instance.
44 *
45 * @var LoggerInterface
46 */
47 private LoggerInterface $logger;
48
49 /**
50 * Registry instance.
51 *
52 * @var FormIntegrationRegistry
53 */
54 private FormIntegrationRegistry $registry;
55
56 /**
57 * Validator instance.
58 *
59 * @var FormSettingsValidator
60 */
61 private FormSettingsValidator $validator;
62
63 /**
64 * Constructor.
65 *
66 * @param LoggerInterface $logger The logger instance.
67 * @param FormIntegrationRegistry $registry The integration registry.
68 * @param FormSettingsValidator $validator The validator instance.
69 */
70 public function __construct(
71 LoggerInterface $logger,
72 FormIntegrationRegistry $registry,
73 FormSettingsValidator $validator
74 ) {
75 $this->logger = $logger;
76 $this->registry = $registry;
77 $this->validator = $validator;
78 }
79
80 /**
81 * Get settings for a specific form.
82 *
83 * @param int $formId The form ID.
84 *
85 * @return FormSettingsDTO The form settings.
86 */
87 public function getSettings( int $formId ): FormSettingsDTO {
88 $data = get_post_meta( $formId, self::META_KEY, true );
89
90 if ( empty( $data ) || ! is_array( $data ) ) {
91 $this->logger->debug(
92 'No settings found for form, returning defaults',
93 array(
94 'plugin' => 'double-opt-in',
95 'form_id' => $formId,
96 )
97 );
98 return FormSettingsDTO::createDefault();
99 }
100
101 return FormSettingsDTO::fromArray( $data );
102 }
103
104 /**
105 * Save settings for a specific form.
106 *
107 * Note: Validation should be done by the caller (e.g., FormSettingsController)
108 * before calling this method. This avoids double-validation issues when filters
109 * modify the DTO between controller validation and save.
110 *
111 * @param int $formId The form ID.
112 * @param FormSettingsDTO $settings The settings to save.
113 *
114 * @return bool True if saved successfully.
115 */
116 public function saveSettings( int $formId, FormSettingsDTO $settings ): bool {
117 $data = $settings->toArray();
118
119 // Apply filter for backward compatibility
120 $data = apply_filters( 'f12_cf7_doubleoptin_save_form', $data );
121
122 $result = update_post_meta( $formId, self::META_KEY, $data );
123
124 // update_post_meta returns false when the value is unchanged,
125 // which is not an error. Verify by reading back the meta.
126 if ( $result === false ) {
127 $saved = get_post_meta( $formId, self::META_KEY, true );
128 if ( $saved != $data ) {
129 $this->logger->error(
130 'Failed to save form settings to database',
131 array(
132 'plugin' => 'double-opt-in',
133 'form_id' => $formId,
134 )
135 );
136 return false;
137 }
138 }
139
140 $this->logger->info(
141 'Form settings saved',
142 array(
143 'plugin' => 'double-opt-in',
144 'form_id' => $formId,
145 'enabled' => $settings->enabled,
146 )
147 );
148
149 // The acceptance-field health check caches its scan for hours.
150 // Someone who just saved these settings very likely did so to fix
151 // what that check reported, and a stale "still broken" would be a
152 // bad answer.
153 StaleConsentFieldCheck::flush();
154
155 return true;
156 }
157
158 /**
159 * Toggle the enabled state of a form.
160 *
161 * Saves directly without full validation since the toggle only changes
162 * the enabled state. Full validation is applied when saving all settings
163 * via the configuration panel.
164 *
165 * @param int $formId The form ID.
166 *
167 * @return bool The new enabled state.
168 */
169 public function toggleEnabled( int $formId ): bool {
170 $settings = $this->getSettings( $formId );
171 $settings->enabled = ! $settings->enabled;
172
173 // Save directly without full validation - toggle only changes the enabled state.
174 // Full form validation (subject, body, recipient, etc.) is enforced when
175 // saving via the configuration panel.
176 $data = $settings->toArray();
177 $data = apply_filters( 'f12_cf7_doubleoptin_save_form', $data );
178 update_post_meta( $formId, self::META_KEY, $data );
179
180 $this->logger->info(
181 'Form toggle state changed',
182 array(
183 'plugin' => 'double-opt-in',
184 'form_id' => $formId,
185 'enabled' => $settings->enabled,
186 )
187 );
188
189 return $settings->enabled;
190 }
191
192 /**
193 * Get all forms from all available integrations.
194 *
195 * @return array Array of form data grouped by integration.
196 */
197 public function getAllForms(): array {
198 $result = array();
199
200 foreach ( $this->registry->getAvailable() as $identifier => $integration ) {
201 $forms = $integration->getForms();
202
203 if ( ! empty( $forms ) ) {
204 $result[ $identifier ] = array(
205 'name' => $integration->getName(),
206 'forms' => $forms,
207 );
208 }
209 }
210
211 $this->logger->debug(
212 'Retrieved all forms from integrations',
213 array(
214 'plugin' => 'double-opt-in',
215 'integration_count' => count( $result ),
216 )
217 );
218
219 return $result;
220 }
221
222 /**
223 * Get a flat list of all forms.
224 *
225 * @return array Array of form data.
226 */
227 public function getAllFormsFlat(): array {
228 $forms = array();
229
230 foreach ( $this->registry->getAvailable() as $identifier => $integration ) {
231 $integrationForms = $integration->getForms();
232
233 foreach ( $integrationForms as $form ) {
234 $form['integration_name'] = $integration->getName();
235 $forms[] = $form;
236 }
237 }
238
239 return $forms;
240 }
241
242 /**
243 * Get form data including settings.
244 *
245 * @param int|string $formId The form ID (can be composite for Elementor: "123_abc456").
246 * @param string $integration The integration identifier.
247 *
248 * @return array|null The form data or null if not found.
249 */
250 public function getFormData( $formId, string $integration = '' ): ?array {
251 // Check if this is a composite ID (e.g., Elementor: "123_abc456")
252 $isCompositeId = is_string( $formId ) && strpos( $formId, '_' ) !== false;
253 $postId = $isCompositeId ? (int) explode( '_', $formId )[0] : (int) $formId;
254
255 // For composite IDs, try to find Elementor integration first
256 if ( $isCompositeId && empty( $integration ) ) {
257 $integration = 'elementor';
258 }
259
260 $integrationInstance = $this->registry->findForForm( $postId, $integration );
261
262 // If not found and composite ID, try to get Elementor integration directly
263 if ( ! $integrationInstance && $isCompositeId ) {
264 $integrationInstance = $this->registry->get( 'elementor' );
265 }
266
267 if ( ! $integrationInstance ) {
268 $this->logger->warning(
269 'Integration not found for form',
270 array(
271 'plugin' => 'double-opt-in',
272 'form_id' => $formId,
273 'post_id' => $postId,
274 'integration' => $integration,
275 )
276 );
277 return null;
278 }
279
280 $post = get_post( $postId );
281 if ( ! $post ) {
282 return null;
283 }
284
285 // For Elementor, use the post ID for settings storage
286 $settingsId = $isCompositeId ? $postId : (int) $formId;
287 $settings = $this->getSettings( $settingsId );
288 $fields = $integrationInstance->getFormFields( $formId );
289
290 // Get title - for Elementor, try to get the form name from the widget
291 $title = $post->post_title;
292 if ( $isCompositeId && method_exists( $integrationInstance, 'getFormTitle' ) ) {
293 $formTitle = $integrationInstance->getFormTitle( $formId );
294 if ( ! empty( $formTitle ) ) {
295 $title = $formTitle;
296 }
297 }
298
299 // For Elementor forms, override the enabled status based on actual DOI action presence
300 // (not from post_meta, but from Elementor's submit_actions)
301 $settingsArray = $settings->toCamelCaseArray();
302 $debugInfo = array(
303 'isCompositeId' => $isCompositeId,
304 'integrationIdentifier' => $integrationInstance->getIdentifier(),
305 'originalEnabled' => $settingsArray['enabled'] ?? false,
306 );
307
308 // Historical behaviour: for Elementor composite IDs we used to
309 // override $settingsArray['enabled'] with the widget-based
310 // isOptInEnabled() value (which reads the submit_actions list
311 // from Elementor's _elementor_data). That override silently
312 // re-enabled the master toggle after the user had disabled it
313 // in our React UI — the FormSettingsPage Switch would flip back
314 // to green on the next refetch because the widget action stayed
315 // in place. With the completeness-gate (plan
316 // doi-completeness-gate.md §2.2 + §2.5) post_meta is now the
317 // authoritative source for what the React UI controls, so the
318 // override has been removed.
319 //
320 // Known semantic gap (tracked in plan/feature-ideas.md): at
321 // runtime, ElementorIntegration::isOptInEnabled() still consults
322 // the widget action list, not the post_meta enable flag. That
323 // means the React toggle and Elementor's submit-time DOI
324 // activation can diverge until we either sync the widget on
325 // save or make the runtime check post_meta-aware. Out of scope
326 // for the completeness-gate ship; tracked as a follow-up.
327
328 // Convert associative fields array to [{name, label}] format for the frontend.
329 // `name` is force-cast to string because WPForms uses integer field IDs
330 // (`$formData['fields'][4]`) and `json_encode` would otherwise emit `"name": 4`
331 // (number). On the React side, `Set.has(consentField)` then misses because the
332 // stored `consentField` is always a string after sanitize_key, but the field
333 // list contains numbers — the validator fires its "field does not exist" banner
334 // even immediately after the user picked the field from the dropdown
335 // (user-reported 2026-05-13).
336 $fieldsList = array();
337 foreach ( $fields as $name => $label ) {
338 $fieldsList[] = array(
339 'name' => (string) $name,
340 'label' => $label,
341 );
342 }
343
344 // Reconcile the stored consent field with the form's real field
345 // names. Settings written before 5.3.2 went through
346 // sanitize_key(), which lowercased them — so an Elementor
347 // checkbox with the id `Datenschutz` sits in post_meta as
348 // `datenschutz`, matches nothing, and the settings page warns
349 // that the field does not exist. Forever: re-picking it from
350 // the dropdown lowercased it again (customer report 2026-08-27).
351 //
352 // The validator no longer mangles new saves; this repairs the
353 // installations that already have a mangled one. Doing it on
354 // read means an untouched site recovers the moment the page is
355 // opened, and the corrected spelling is what the next save
356 // persists.
357 //
358 // A name that matches NOTHING is left exactly as it is — the
359 // field really was removed from the form, and the red banner
360 // saying so is the correct answer.
361 $storedConsentField = (string) ( $settingsArray['consentField'] ?? '' );
362 if ( $storedConsentField !== '' ) {
363 $canonical = SubmittedContent::matchFieldName( $storedConsentField, array_keys( $fields ) );
364 if ( $canonical !== '' && $canonical !== $storedConsentField ) {
365 $this->logger->info(
366 'Repaired a consent field name that only differed in case',
367 array(
368 'plugin' => 'double-opt-in',
369 'form_id' => $formId,
370 'stored' => $storedConsentField,
371 'form' => $canonical,
372 )
373 );
374 $settingsArray['consentField'] = $canonical;
375 }
376 }
377
378 return array(
379 'id' => $formId,
380 'title' => $title,
381 'integration' => $integrationInstance->getIdentifier(),
382 'integrationName' => $integrationInstance->getName(),
383 'editUrl' => $integrationInstance->getFormEditUrl( $formId ),
384 'settings' => $settingsArray,
385 'fields' => $fieldsList,
386 );
387 }
388
389 /**
390 * Get available templates for a form.
391 *
392 * @param int|string $formId The form ID (can be composite for Elementor).
393 *
394 * @return array Array of template key => label.
395 */
396 public function getAvailableTemplates( $formId ): array {
397 // Handle composite IDs
398 $postId = is_string( $formId ) && strpos( $formId, '_' ) !== false
399 ? (int) explode( '_', $formId )[0]
400 : (int) $formId;
401
402 $integration = $this->registry->findForForm( $postId );
403
404 // For Elementor composite IDs, try to get the integration directly
405 if ( ! $integration && is_string( $formId ) && strpos( $formId, '_' ) !== false ) {
406 $integration = $this->registry->get( 'elementor' );
407 }
408
409 if ( $integration && method_exists( $integration, 'getAvailableTemplates' ) ) {
410 return $integration->getAvailableTemplates();
411 }
412
413 // Default templates
414 return array(
415 'blank' => 'blank',
416 'newsletter_en' => 'newsletter_en',
417 'newsletter_en_2' => 'newsletter_en_2',
418 'newsletter_en_3' => 'newsletter_en_3',
419 );
420 }
421
422 /**
423 * Get available categories.
424 *
425 * @return array Array of category ID => name.
426 */
427 public function getAvailableCategories(): array {
428 $categories = array( 0 => __( 'Please select', 'double-opt-in' ) );
429
430 $list = \forge12\contactform7\CF7DoubleOptIn\Category::get_list(
431 array(
432 'perPage' => -1,
433 'orderBy' => 'name',
434 'order' => 'ASC',
435 ),
436 $numberOfPages
437 );
438
439 foreach ( $list as $category ) {
440 $categories[ $category->get_id() ] = $category->get_name();
441 }
442
443 return $categories;
444 }
445
446 /**
447 * Get template details for preview in the settings panel.
448 *
449 * Returns an array of custom templates with id, title, and thumbnail.
450 *
451 * @return array
452 */
453 public function getTemplateDetails(): array {
454 $repository = new EmailTemplateRepository();
455 $templates = $repository->findAll(
456 array(
457 'post_status' => array( 'publish', 'draft' ),
458 )
459 );
460
461 $details = array();
462 foreach ( $templates as $template ) {
463 $key = 'custom_' . $template['id'];
464 $details[ $key ] = array(
465 'id' => $template['id'],
466 'title' => $template['title'],
467 'thumbnail' => $template['thumbnail'],
468 'editUrl' => admin_url( 'admin.php?page=f12-doi-admin#/email-templates/' . $template['id'] . '/edit' ),
469 );
470 }
471
472 return $details;
473 }
474
475 /**
476 * Get available pages for confirmation page selection.
477 *
478 * @return array Array of page ID => title.
479 */
480 public function getAvailablePages(): array {
481 $pages = array( -1 => __( 'Default', 'double-opt-in' ) );
482
483 $allPages = get_pages();
484 foreach ( $allPages as $page ) {
485 $pages[ $page->ID ] = $page->post_title;
486 }
487
488 return $pages;
489 }
490 }
491