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

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

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