PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.1.5
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.1.5
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.1.5, at src/FormSettings/FormSettingsService.php

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