Jake Vanderwerf
2026-05-13 226b50642af0895948fbaa623a9b7180399a63b6
inc/rest/routes/FormRoutes.php
@@ -1,12 +1,14 @@
<?php
namespace JVBase\rest\routes;
use JVBase\rest\RestRouteManager;
use JVBase\managers\CacheManager;
use JVBase\meta\MetaManager;
use JVBase\managers\CloudflareTurnstile;
use JVBase\meta\Sanitizer;
use JVBase\meta\Validator;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
use JVBase\managers\Cache;
use JVBase\blocks\FormBlock;
use JVBase\utility\Features;
use JVBase\rest\Route;
use JVBase\base\Site;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -21,18 +23,15 @@
 *
 * Handles REST API endpoints for form submissions
 */
class FormRoutes extends RestRouteManager
class FormRoutes extends Rest
{
   protected CacheManager $cache;
   protected FormBlock $form_block;
   protected CloudflareTurnstile|null $turnstile;
   public function __construct()
   {
      $this->cacheName = 'forms';
      $this->cacheTtl = HOUR_IN_SECONDS;
      parent::__construct();
      $this->action = 'form-';
      $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS);
      // Add query vars
      add_filter('query_vars', [$this, 'addQueryVars']);
@@ -45,35 +44,29 @@
    */
   public function registerRoutes(): void
   {
      // Form submission endpoint
      register_rest_route($this->namespace, '/forms', [
         [
            'methods' => 'POST',
            'callback' => [$this, 'submitForm'],
            'permission_callback' => [$this, 'checkRateLimit'], // Public endpoint, rate limited
         ],
         [
            'methods' => 'GET',
            'callback' => [$this, 'getForms'],
            'permission_callback' => [$this, 'checkPermission']
         ]
      ]);
      // ['actionNonce'=>'dash-']
      Route::for('forms')
         ->post([$this, 'submitForm'])
         ->args([
            'form_type' => 'string|required',
            'form_id' => 'string|required',
            'timestamp' => 'string',
            'cf-turnstile-response' => 'string',
         ])
         ->auth('public')
         ->rateLimit(5) // 5 submissions per minute
         ->get([$this, 'getForms'])
         ->auth(PermissionHandler::combine(['logged_in', ['actionNonce'=>'dash-']]))
         ->rateLimit(30)
         ->register();
      // Get specific form configuration
      register_rest_route($this->namespace, '/forms/(?P<form_type>[a-zA-Z0-9_-]+)', [
         [
            'methods' => 'GET',
            'callback' => [$this, 'getForm'],
            'permission_callback' => [$this, 'checkPermission'],
            'args' => [
               'form_type' => [
                  'required' => true,
                  'type' => 'string',
                  'sanitize_callback' => 'sanitize_text_field'
               ]
            ]
         ]
      ]);
      Route::for(Route::pattern('forms/{form_type}'))
         ->get([$this, 'getForm'])
         ->arg('form_type', 'string|required')
         ->auth('logged_in')
         ->rateLimit(30)
         ->register();
   }
   /**
@@ -100,31 +93,35 @@
         $result = $this->handleFormSubmission($form_type, $form_id, $form_data, $files);
         if (is_wp_error($result)) {
            return new WP_REST_Response([
               'success'   => false,
               'message'   => $result->get_error_message()
            ]);
            return $this->error(
               $result->get_error_message(),
               $result->get_error_code(),
               400
            );
         }
         if (array_key_exists('success', $result)){
            return new WP_REST_Response($result);
            return $this->validationError($result);
         }
         return new WP_REST_Response([
            'success' => true,
            'data'   => $result
         ], 200);
         return $this->success($result);
      } catch (Exception $e) {
         return new WP_REST_Response([
            'success' => false,
            'message' => 'An error occurred while processing your submission.'
         ], 500);
         $this->logError('Form submission error', [
            'message' => $e->getMessage(),
            'trace' => $e->getTraceAsString()
         ]);
         return $this->error(
            'An error occurred while processing your submission.',
            'submission_error',
            500
         );
      }
   }
   protected function verifyTurnstile(string $token): bool
   {
      if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
      if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
         return true;
      }
@@ -142,7 +139,6 @@
   {
      // Get form configuration
      $form_config = FormBlock::getForm($form_type);
      if (!$form_config) {
         return new WP_Error('invalid_form', 'Form configuration not found.');
      }
@@ -169,7 +165,6 @@
      } catch (\Exception $e) {
         return new WP_Error('validation_failed', 'Data validation error: ' . $e->getMessage());
      }
      if (array_key_exists('success', $processed_data) && $processed_data['success'] === false) {
         return $processed_data;
      }
@@ -178,7 +173,6 @@
      try {
         $email_sent = $this->sendEmailNotification($form_type, $form_config, $processed_data, $files);
      } catch (\Exception $e) {
         ;
         return new WP_Error('email_failed', 'Email error: ' . $e->getMessage());
      }
@@ -203,7 +197,9 @@
    */
   protected function validateAndSanitizeData(array $form_config, array $form_data): array|WP_REST_Response
   {
      $meta = new MetaManager(null, 'form');
      $validator = new Validator();
      $sanitizer = new Sanitizer();
      $processed_data = [];
      $errors = [];
@@ -226,7 +222,7 @@
         $value = $normalized_form_data[$field_name] ?? '';
         if (in_array($field_config['type'], ['checkbox', 'set'])) {
            $value = jvbCommaList((is_array($value)) ? $value : explode(',',$value));
            $value = is_array($value) ? implode(',', $value) : $value;
         }
         // Check required fields
@@ -244,7 +240,7 @@
         $field_config['name'] = $field_name;
         // Validate field
         if (!$meta->validator->validate($value, $field_config)) {
         if (!$validator->validate($value, $field_config)) {
            $label = $field_config['label'] ?? ucfirst(str_replace('_', ' ', $field_name));
            $errors['errors'][$field_name] = [
               'message' => sprintf('Field "%s" contains invalid data.', $label)
@@ -253,7 +249,7 @@
         }
         // Sanitize field
         $processed_data[$field_name] = $meta->sanitizer->sanitize($value, $field_config);
         $processed_data[$field_name] = $sanitizer->sanitize($value, $field_config);
      }
      if (!empty($errors)) {
@@ -267,9 +263,6 @@
   /**
    * Send email notification
    */
   /**
    * Send email notification
    */
   protected function sendEmailNotification(string $form_type, array $form_config, array $form_data, array $files = []): bool
   {
      $admin_email = apply_filters('jvb_form_email_to', $form_config['email_to'], $form_type, $form_data);
@@ -286,6 +279,20 @@
         $submitter_name = $form_data['name'];
      }
      if (array_key_exists('preheader', $form_config)) {
         $preheader = $form_config['preheader'];
      } else {
         $submitter_name = $submitter_name?:'website visitor';
         $preheader = sprintf(
            'New %s submission from %s',
            $form_config['title'],
            $submitter_name
         );
      }
      $subject .= ' ' . $submitter_name;
      // Email headers
      $headers = [];
@@ -319,23 +326,36 @@
         );
      }
      // Build form data array for table
      // Build form data array for table - using similar logic to JS
      $skip_fields = array_merge(
         ['sendAll', 'action', 'form_id', 'form_type', 'timestamp', '_wpnonce', '_wp_http_referer', 'cf-turnstile-response'],
         $form_config['ignore'] ?? []
      );
      $form = [];
      foreach ($form_config['fields'] as $field_name => $config) {
         // Skip file upload fields
         if ($config['type'] == 'upload') {
         // Skip ignored fields
         if (in_array($field_name, $skip_fields)) {
            continue;
         }
         // Skip upload fields (handled separately)
         if ($config['type'] === 'upload') {
            continue;
         }
         $value = $form_data[$field_name] ?? '';
         // Skip empty fields
         if (empty($value)) {
         if ($this->isEmptyValue($value)) {
            continue;
         }
         // Get label - check for summaryTitle first, then label (similar to JS checking legend then label)
         $label = $config['summaryTitle'] ?? $config['label'] ?? ucfirst(str_replace('_', ' ', $field_name));
         $form[] = [
            'label' => $config['summaryTitle'] ?? $config['label'],
            'label' => $label,
            'value' => $this->formatFieldValueForEmail($field_name, $value, $config)
         ];
      }
@@ -359,17 +379,34 @@
         $subject,
         $body,
         '',
         $preheader,
         $headers,
         $attachments
      );
   }
   /**
    * Check if value is empty (similar to JS isEmptyValue)
    */
   protected function isEmptyValue($value): bool
   {
      if ($value === null || $value === '' || $value === false) {
         return true;
      }
      if (is_array($value) && empty($value)) {
         return true;
      }
      return false;
   }
   /**
    * Format field value for email display
    */
   protected function formatFieldValueForEmail(string $field_name, mixed $value, array $field_config): string
   {
      if (empty($value)) {
      if ($this->isEmptyValue($value)) {
         return '';
      }
@@ -377,9 +414,17 @@
      $type = $field_config['subType'] ?? $type;
      switch ($type) {
         case 'repeater':
            return $this->formatRepeaterForEmail($value, $field_config);
         case 'tag-list':
            return $this->formatTagListForEmail($value);
         case 'location':
            return $this->formatLocationForEmail($value);
         case 'phone':
         case 'tel':
            // Format phone number as xxx-xxx-xxxx
            $cleaned = preg_replace('/\D/', '', $value);
            if (strlen($cleaned) === 10) {
               return substr($cleaned, 0, 3) . '-' . substr($cleaned, 3, 3) . '-' . substr($cleaned, 6);
@@ -388,51 +433,101 @@
         case 'checkbox':
         case 'set':
            // Convert array of values to comma-separated labels
            if (!is_array($value)) {
               $value = explode(',', $value);
            }
            $labels = [];
            foreach ($value as $val) {
            $values = explode(',', $value);
            $labels = array_map(function($val) use ($field_config) {
               $val = trim($val);
               if (isset($field_config['options'][$val])) {
                  $labels[] = $field_config['options'][$val];
               } else {
                  $labels[] = $val; // Fallback to value if no label found
               }
            }
               return $field_config['options'][$val] ?? ucfirst(str_replace('_', ' ', $val));
            }, $values);
            return implode(', ', $labels);
         case 'radio':
         case 'select':
            // Return label instead of value
            if (isset($field_config['options'][$value])) {
               return $field_config['options'][$value];
            }
            return $value;
         case 'textarea':
         case 'wysiwyg':
            // Keep line breaks for email display
            return $value; // nl2br is already applied in the email body
            // Fallback: capitalize the value
            return ucfirst(str_replace('_', ' ', $value));
         case 'true_false':
            $true_text = $field_config['true_text'] ?? 'Yes';
            $false_text = $field_config['false_text'] ?? 'No';
            return ($value === '1' || $value === 1 || $value === true) ? $true_text : $false_text;
         case 'email':
            return $value; // Will be clickable in email client
         case 'url':
            return $value; // Will be clickable in email client
         default:
            return is_array($value) ? implode(', ', $value) : $value;
      }
   }
   /**
    * Format repeater field for email
    */
   protected function formatRepeaterForEmail(array $rows, array $field_config): string
   {
      $output = '';
      foreach ($rows as $index => $row) {
         $output .= '<strong>Entry ' . ($index + 1) . ':</strong><br>';
         foreach ($row as $sub_field => $sub_value) {
            if ($this->isEmptyValue($sub_value)) {
               continue;
            }
            // Get sub-field label if available
            $sub_config = $field_config['sub_fields'][$sub_field] ?? [];
            $label = $sub_config['label'] ?? ucfirst(str_replace('_', ' ', $sub_field));
            $output .= '&nbsp;&nbsp;' . esc_html($label) . ': ' . esc_html($sub_value) . '<br>';
         }
         $output .= '<br>';
      }
      return $output;
   }
   /**
    * Format tag-list field for email
    */
   protected function formatTagListForEmail(array $tags): string
   {
      $items = [];
      foreach ($tags as $tag) {
         // Get first non-empty value as display
         $display = '';
         foreach ($tag as $field => $value) {
            if (!$this->isEmptyValue($value)) {
               $display = $value;
               break;
            }
         }
         if ($display) {
            $items[] = $display;
         }
      }
      return implode(', ', $items);
   }
   /**
    * Format location field for email
    */
   protected function formatLocationForEmail(array $location): string
   {
      $parts = [];
      if (!empty($location['street'])) $parts[] = $location['street'];
      if (!empty($location['city'])) $parts[] = $location['city'];
      if (!empty($location['province'])) $parts[] = $location['province'];
      if (!empty($location['postal_code'])) $parts[] = $location['postal_code'];
      if (!empty($location['country'])) $parts[] = $location['country'];
      return !empty($parts) ? implode(', ', $parts) : ($location['address'] ?? '');
   }
   /**
    * Process uploaded files for email attachments
    * Files are in PHP's tmp directory and will be automatically cleaned up after request
    */
@@ -618,7 +713,7 @@
         ];
      }
      return new WP_REST_Response($public_forms, 200);
      return $this->success($public_forms);
   }
   /**
@@ -626,18 +721,16 @@
    */
   public function getForm(WP_REST_Request $request): WP_REST_Response
   {
      $form_type = $request->get_param('form_type');
      $form_type = sanitize_text_field($request->get_param('form_type'));
      $form_config = FormBlock::getForm($form_type);
      if (!$form_config) {
         return new WP_REST_Response([
            'error' => 'Form not found'
         ], 404);
         return $this->notFound('Form not found');
      }
      // Remove sensitive data
      unset($form_config['email_to']);
      return new WP_REST_Response($form_config, 200);
      return $this->success($form_config);
   }
}