PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.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 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 All 42 releases
double-opt-in / src / FormSettings / FormSettingsService.php

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

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