Jake Vanderwerf
2026-09-18 a6413da67a4579866f0fd0323ddb23a7b6b9654a
inc/rest/routes/UploadRoutes.php
@@ -1,17 +1,17 @@
<?php
namespace JVBase\rest\routes;
use JVBase\managers\queue\executors\UploadExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\Instance;
use JVBase\managers\Errors;
use JVBase\managers\queue\Queue;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
use JVBase\meta\Meta;
use JVBase\managers\UploadManager;
use JVBase\rest\Route;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use Exception;
if (!defined('ABSPATH')) {
@@ -19,67 +19,15 @@
}
class UploadRoutes extends Rest
{
   use Instance;
    public function __construct()
    {
        parent::__construct();
      add_action('init', [$this, 'registerUploadExecutors'], 5);
    }
   /**
    * Register upload operation types with the queue's TypeRegistry
    */
   public function registerUploadExecutors(): void
   {
      $registry = JVB()->queue()->registry();
      $executor = new UploadExecutor();
      // Image uploads - chunked at 5 files
      $registry->register('image_upload', new TypeConfig(
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 5
      ));
      // Video uploads - one at a time (heavy processing)
      $registry->register('video_upload', new TypeConfig(
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 1
      ));
      // Document uploads - chunked at 10
      $registry->register('document_upload', new TypeConfig(
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 10
      ));
      // Metadata updates
      $registry->register('update_image_meta', new TypeConfig(
         executor: $executor
      ));
      // Cleanup - chunked at 5
      $registry->register('temporary_cleanup', new TypeConfig(
         executor: $executor,
         chunkKey: 'files',
         chunkSize: 5
      ));
      // Attach to content (depends on upload completing)
      $registry->register('attach_upload_to_content', new TypeConfig(
         executor: $executor
      ));
      // Process upload groups into posts
      $registry->register('process_upload_groups', new TypeConfig(
         executor: $executor,
         chunkKey: 'posts',
         chunkSize: 5
      ));
   }
    /**
     * Registers upload routes
@@ -100,7 +48,6 @@
         ->args([
            'id'     => 'string|required',
            'content'   => 'string|required',
            'user'      => 'int|required'
         ])
         ->register();
@@ -109,8 +56,8 @@
         ->auth(PermissionHandler::combine(['nonce']))
         ->rateLimit(30)
         ->args([
            'user'   => 'int|required',
            'items'  => 'array|required'
            'items'  => 'array|required',
            'id'    => 'string'
         ])
         ->register();
    }
@@ -119,11 +66,15 @@
    * Build the main $context for UploadManager from the $request params
    * @param WP_REST_Request $request
    * @return array
    * @throws Exception
    * @throws Exception
    */
    protected function buildUploadArgs(WP_REST_Request $request):array
    {
        $data = $request->get_params();
      $args = [];
      $registrar = Registrar::getInstance($data['content']??'');
      foreach ($data as $key => $value) {
         switch ($key) {
            case 'depends_on':
@@ -134,15 +85,25 @@
            case 'item_id':
               if (is_numeric($value)) {
                  $args['item_id'] = absint($value);
                  if (!array_key_exists('post_id', $args)) {
                     $args['post_id'] = absint($value);
                  if ($registrar) {
                     switch ($registrar->getType()) {
                        case 'post':
                           $args['post_id'] = absint($value);
                           break;
                        case 'term':
                           $args['term_id'] = absint($value);
                           break;
                        case 'user':
                           $args['user_id'] = absint($value);
                           break;
                     }
                  }
               }
               break;
            // Post Type/Taxonomy
            case 'content':
               $key = str_replace('-', '_', $key);
               if ($value === 'options' || array_key_exists($value, JVB_CONTENT) || Features::forTaxonomy($key)->has('is_content')) {
               $value = str_replace('-', '_', $value);
               if ($value === 'options' || $registrar) {
                  $args['content'] = $value;
               }
               break;
@@ -156,15 +117,13 @@
               break;
            // User ID
            case 'user':
               if ($this->userCheck($value)) {
                  $args['user'] = (int) $value;
                  if (!array_key_exists('post_id', $args) &&
                     !array_key_exists('post_id', $data) &&
                     !array_key_exists('term_id', $data) &&
                     !array_key_exists('item_id', $data)) {
                     $args['post_id'] = (int)get_user_meta((int) $value, BASE.'link', true);
                  }
               if (!array_key_exists('post_id', $args) &&
                  !array_key_exists('post_id', $data) &&
                  !array_key_exists('term_id', $data) &&
                  !array_key_exists('item_id', $data)) {
                  $args['post_id'] = (int)get_user_meta((int) $value, BASE.'profile_link', true);
               }
               break;
            // Operation ID
            case 'id':
@@ -243,13 +202,13 @@
            case 'field_key':
            case 'field_type':
            case 'subtype':
            case 'item_id':
            case 'context':
               if (is_string($value)) {
                  $args[$key] = sanitize_text_field($value);
               }
               break;
         }
         $args['user'] = get_current_user_id();
      }
        return $args;
    }
@@ -268,8 +227,8 @@
         $files = $request->get_file_params();
         $args = $this->buildUploadArgs($request);
         if (!$args['user']) {
         $userID = get_current_user_id();
         if (!$userID) {
             return $this->unauthorized();
         }
         if (!$args['content']) {
@@ -280,7 +239,7 @@
         $secured_files = $this->secureFiles($files, $args);
         if (empty($secured_files)) {
            $this->logError('No valid files to upload');
            $this->logError('No valid files to upload', 'handleUpload');
            return $this->error('No valid files to upload');
         }
@@ -291,15 +250,7 @@
      } catch (Exception $e) {
         // Error handling...
         JVB()->error()->log(
            '[UploadRoutes]:handleUploadRequest',
            $e->getMessage(),
            [
               'request_data' => $request->get_params(),
               'files_info' => $this->getFilesInfo($_FILES),
               'trace' => $e->getTraceAsString()
            ]
         );
         $this->logError($e->getMessage(), 'handleUpload');
         return $this->error($e->getMessage());
      }
   }
@@ -309,7 +260,7 @@
    */
   protected function secureFiles(array $files, array $args): array
   {
      $uploader = new UploadManager();
      $uploader = UploadManager::getInstance();
      $secured_files = [];
      $errors = [];
@@ -395,9 +346,11 @@
         $chunkSize = 10;
      }
      JVB()->queue()->queueOperation(
      error_log('Queueing Operation: '.print_r($operation_type, true));
      error_log('With ID: '.print_r($args['upload'], true));
      $queuedProcessing = Queue::getInstance()->queueOperation(
         $operation_type,
         $args['user'],
         get_current_user_id(),
         array_merge(
            ['secured_files' => $secured_data['files']],
            $args
@@ -409,39 +362,29 @@
         ]
      );
      error_log('queuedProcessing operation: '.print_r($queuedProcessing, true));
      $uploadOpId = $queuedProcessing['operation_id'];
      if ($args['mode'] !== 'selection') {
         $dependencies = [$args['upload']];
         if (!empty($args['depends_on'])) {
            $dependencies[] = $args['depends_on'];
         // Only create attach_upload_to_content if the upload was NOT merged.
         // When merged, the original upload's attach_upload_to_content
         // will handle all files after the merged image_upload completes.
         if (!$queuedProcessing['updated_existing']) {
            Queue::getInstance()->queueOperation(
               'attach_upload_to_content',
               get_current_user_id(),
               $args,
               [
                  'priority'      => 'high',
                  'operation_id'  => $args['id'],
                  'depends_on'    => [$uploadOpId]
               ]
            );
         }
         JVB()->queue()->queueOperation(
            'attach_upload_to_content',
            $args['user'],
            $args,
            [
               'priority'      => 'high',
               'operation_id'  => $args['id'],
               'depends_on'    => $dependencies
            ]
         );
      }
      JVB()->queue()->queueOperation(
         'temporary_cleanup',
         $args['user'],
         [
            'files'     => $secured_data['files'],
            'context'   => $args,
         ],
         [
            'priority'      => 'low',
            'chunk_size'    => 5,
            'chunk_key'     => 'files',
            'depends_on'    => $args['upload']
         ]
      );
      return $args['id'];
   }
@@ -459,145 +402,8 @@
      };
   }
   /**
    * Step 3: Process operation from queue
    * Called by OperationQueue
    * @param WP_Error|array $result
    * @param object $operation
    * @param array $data
    *
    * @return array|WP_Error
    */
   public function processOperation(array $result, object $operation, array $data): array
   {
      // Only handle our operation types
      $handled_types = [
         'image_upload',
         'video_upload',
         'document_upload',
         'update_metadata',
         'temporary_cleanup',
         'attach_upload_to_content',
         'process_upload_groups'
      ];
      if (!in_array($operation->type, $handled_types)) {
         return $result; // Not our operation, pass through
      }
      try {
         // Route to appropriate handler
         $handler_result = match($operation->type) {
            'image_upload' => $this->processImageUpload($operation, $data),
            'video_upload' => $this->processVideoUpload($operation, $data),
            'document_upload' => $this->processDocumentUpload($operation, $data),
            'update_metadata' => $this->processUploadMeta($result, $operation, $data),
            'temporary_cleanup' => $this->processTemporaryCleanup($result, $operation, $data),
            'attach_upload_to_content' => $this->processAttachToContent($operation, $data),
            'process_upload_groups' => $this->processUploadGroups($result, $operation, $data),
            default => new WP_Error('unknown_type', 'Unknown operation type')
         };
         // Handle WP_Error
         if (is_wp_error($handler_result)) {
            return [
               'success' => false,
               'result' => $handler_result->get_error_message()
            ];
         }
         return $handler_result;
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processOperation',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'operation_type' => $operation->type
            ]
         );
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Standardize processing result
    */
   protected function standardizeResult(array $result): array
   {
      return [
         'attachment_id' => $result['attachment_id'],
         'url' => $result['url'],
         'file' => $result['file'],
         'upload_id' => $result['upload_id'] ?? null
      ];
   }
   protected function processAttachToContent(object $operation, array $data): array
   {
      try {
         // Get the results from the upload operation
         $upload_results = JVB()->queue()->getOperationValue($data['upload'], 'result', true);
         if (empty($upload_results)) {
            throw new Exception('No upload results found for operation: ' . $data['upload']);
         }
         if (empty($data['post_id']) || str_starts_with((string)($data['item_id'] ?? ''), 'new')) {
            foreach ($operation->dependencies as $depId) {
               $dep = JVB()->queue()->get($depId);
               if ($dep && $dep->type === 'content_update' && !empty($dep->result['new_posts'])) {
                  $itemId = $data['item_id'] ?? null;
                  if ($itemId && isset($dep->result['new_posts'][$itemId])) {
                     $data['post_id'] = $dep->result['new_posts'][$itemId];
                     break;
                  }
               }
            }
            if (empty($data['post_id'])) {
               throw new Exception('Could not resolve post_id from dependencies');
            }
         }
         // Now attach to the specified content
         if (!empty($data['field_name'])) {
            $this->updateFieldValue($data, $upload_results);
         }
         return [
            'success' => true,
            'result' => 'Attachments linked to content'
         ];
      } catch (Exception $e) {
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Cleanup temporary files after processing
    */
   protected function cleanupTempFiles(array $secured_files, int $user_id): void
   {
      $uploader = new UploadManager();
      foreach ($secured_files as $secured) {
         if (!empty($secured['temp_path']) && file_exists($secured['temp_path'])) {
            @unlink($secured['temp_path']);
         }
      }
      // Clean up empty directories
      $uploader->cleanupEmptyTempDirs($user_id);
   }
   /**
    * Update field value with new attachment IDs
@@ -614,7 +420,7 @@
      } elseif (array_key_exists('term_id', $data)) {
         $meta = Meta::forTerm($data['term_id']);
      } else {
         $link = (int)get_user_meta($data['user'], BASE.'link');
         $link = (int)get_user_meta($data['user'], BASE.'profile_link');
         $meta = Meta::forPost($link);
      }
@@ -627,162 +433,8 @@
      // Update with comma-separated string
      $meta->set($data['field_name'], implode(',', $all_ids));
      $meta->save();
   }
   /**
    * Generic file upload processor - works for images, videos, documents
    */
   protected function processFileUpload(object $operation, array $data, string $file_type): WP_Error|array
   {
      try {
         $uploader = new UploadManager();
         $processed_results = [];
         $config = $this->getFieldConfig($data);
         $args = $data;
         unset($args['secured_files']);
         foreach ($data['secured_files'] as $secured_file) {
            $result = $uploader->processUpload(
               $secured_file['temp_path'],
               array_merge(
                  $config,
                  [
                     'file_type' => $file_type,
                     'user_id' => $operation->user_id,
                     'post_id' => (int)($data['post_id'] ?? 0),
                     'term_id' => (int)($data['term_id'] ?? 0),
                     'original_name' => $secured_file['original_name'],
                     'mime_type' => $secured_file['mime_type'],
                     'content' => $data['content'] ?? '',
                  ]
               )
            );
            if (!is_wp_error($result)) {
               $standardized = $this->standardizeResult($result);
               // Use embedded upload_id from the secured file itself
               $standardized['upload_id'] = $secured_file['upload_id'] ?? null;
               if ($standardized['upload_id']) {
                  $processed_results[$standardized['upload_id']] = $standardized;
               } else {
                  $processed_results[] = $standardized;
               }
               // Apply frontend metadata if provided
               if (!empty($secured_file['metadata'])) {
                  $this->applyMeta($standardized['attachment_id'], $secured_file['metadata']);
               }
            }
         }
         $this->handleUploadDestination($data, $processed_results);
         // Cleanup temporary files
         $this->cleanupTempFiles($data['secured_files'], $operation->user_id);
         return [
            'success' => true,
            'result' => $processed_results
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processFileUpload',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'file_type' => $file_type,
               'user_id' => $operation->user_id
            ]
         );
         return [
            'success'   => false,
            'result'    => $e->getMessage()
         ];
      }
   }
   /**
    * Process image uploads
    */
   protected function processImageUpload(object $operation, array $data): WP_Error|array
   {
      return $this->processFileUpload($operation, $data, 'image');
   }
   /**
    * Process video uploads
    */
   protected function processVideoUpload(object $operation, array $data): WP_Error|array
   {
      return $this->processFileUpload($operation, $data, 'video');
   }
   /**
    * Process document uploads
    */
   protected function processDocumentUpload(object $operation, array $data): WP_Error|array
   {
      return $this->processFileUpload($operation, $data, 'document');
   }
   /**
    * Process temporary cleanup operation
    * Called by OperationQueue for 'temporary_cleanup' operations
    *
    * @param array $result
    * @param object $operation
    * @param array $data
    * @return array
    */
   protected function processTemporaryCleanup(array $result, object $operation, array $data): array
   {
      try {
         // Cleanup temporary files if they exist
         if (!empty($data['secured_files'])) {
            $this->cleanupTempFiles($data['secured_files'], $operation->user_id);
         }
         // If specific temp paths provided
         if (!empty($data['temp_paths']) && is_array($data['temp_paths'])) {
            foreach ($data['temp_paths'] as $temp_path) {
               if (file_exists($temp_path)) {
                  @unlink($temp_path);
               }
            }
         }
         // Cleanup empty temp directories for this user
         if (!empty($operation->user_id)) {
            $uploader = new UploadManager();
            $uploader->cleanupEmptyTempDirs($operation->user_id);
         }
         return [
            'success' => true,
            'result' => 'Temporary files cleaned up successfully'
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processTemporaryCleanup',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'user_id' => $operation->user_id
            ]
         );
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Handle metadata update requests
@@ -812,10 +464,12 @@
         if (!empty($attachments)) {
            error_log('Attachments: '.print_r($attachments, true));
            return $this->queueMetaUpdate($attachments, $data['user']);
            return $this->queueMetaUpdate($attachments, absint($data['user']), sanitize_text_field($data['id']??''));
         }
         return $this->sendResponse(
            false,
            ['error_code' => 'missing_identifiers'],
@@ -823,14 +477,7 @@
         );
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:handleMetadataUpdate',
            $e->getMessage(),
            [
               'request_data' => $request->get_params(),
               'trace' => $e->getTraceAsString()
            ]
         );
         $this->logError($e->getMessage(), 'handleMetadataUpdate');
         return $this->sendResponse(
            false,
@@ -882,9 +529,9 @@
   /**
    * Queue metadata update with dependency on upload operation
    */
   protected function queueMetaUpdate(array $data, int $user): WP_REST_Response
   protected function queueMetaUpdate(array $data, int $user, ?string $operationId = null): WP_REST_Response
   {
      $queue = JVB()->queue();
      $queue = Queue::getInstance();
      $depends_on = [];
      $errors = [];
      $original = count($data);
@@ -893,95 +540,30 @@
            $depends_on[] = $info['depends_on'];
         }
      }
      $queueData = [
         'depends_on' => $depends_on,
      ];
      if ($operationId) {
         $queueData['operation_id'] = $operationId;
      }
      $operationID = $queue->queueOperation(
         'update_image_meta',
         $user,
         $data,
         [
            'depends_on' => $depends_on,
         ]
         $queueData
      );
      return $this->sendResponse(
         true,
         [
            'operation_id' => $operationID,
            'operation_id' => $operationID['operation_id']??$operationId,
            'message'      => "Successfully queued ".count($data)." of {$original} meta updates"
         ],
         'Metadata update queued - will apply after upload completes'
      );
   }
   /**
    * Process metadata update operation (called by queue processor)
    */
   public function processUploadMeta(WP_Error|array $result, object $operation, array $data): WP_Error|array
   {
      try {
         if (!is_array($operation->depends_on)) {
            $operation->depends_on = [$operation->depends_on];
         }
         $updated_count = 0;
         $errors = [];
         foreach ($operation->depends_on as $dependency) {
            $operationData = JVB()->queue()->getOperation($dependency);
            if (!$operationData || $operationData->state !== 'completed') {
               throw new Exception('Original upload operation not found or not completed');
            }
            $uploadResults = json_decode($operationData->result, true);
            if (!$uploadResults || !$uploadResults['success']) {
               throw new Exception('Original upload operation failed');
            }
            $attachmentsToUpdate = array_filter($data, function ($item) use ($dependency) {
               return $item['depends_on'] === $dependency;
            });
            $uploadIDToAttachment = $this->buildUploadToAttachmentMapping(
               array_keys($attachmentsToUpdate),
               $uploadResults['data']
            );
            if (empty($uploadIDToAttachment)) {
               throw new Exception('No valid upload ID to attachment ID mappings found');
            }
            foreach ($uploadIDToAttachment as $uploadId => $attachmentId) {
               try {
                  $this->applyMeta($attachmentId, $attachmentsToUpdate[$uploadId]);
                  $updated_count++;
               } catch (Exception $e) {
                  $errors[] = "Upload {$uploadId} (Attachment {$attachmentId}): " . $e->getMessage();
               }
            }
         }
         return [
            'success' => $updated_count > 0,
            'result' => [
               'updated_count' => $updated_count,
               'mappings' => $uploadIDToAttachment,
               'errors' => $errors,
               'message' => "Applied metadata to {$updated_count} attachment(s)"
            ]
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processUploadMeta',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'original_operation_id' => $data['original_operation_id'] ?? 'unknown'
            ]
         );
         return [
            'success'   => false,
            'result' => $e->getMessage()
         ];
      }
   }
   protected function applyMeta(int $attachment_id, array $metadata): void
   {
@@ -1007,31 +589,9 @@
   }
   /**
    * Build mapping from frontend upload IDs to WordPress attachment IDs
    */
   protected function buildUploadToAttachmentMapping(array $upload_ids, array $results): array
   {
      $mapping = [];
      foreach ($results as $result) {
         if (!isset($result['upload_id']) || !isset($result['attachment_id'])) {
            continue;
         }
         $upload_id = $result['upload_id'];
         $attachment_id = $result['attachment_id'];
         // Validate that this upload_id was requested
         if (in_array($upload_id, $upload_ids)) {
            $mapping[$upload_id] = $attachment_id;
         }
      }
      return $mapping;
   }
   /**
    * Verify user has access to attachment
    * @param int $attachment_id
    * @param int $user_id
    * @return bool
    */
   protected function verifyAttachmentAccess(int $attachment_id, int $user_id): bool
   {
@@ -1046,59 +606,9 @@
   }
   /**
    * Get field configuration for upload processing
    */
   protected function getFieldConfig(array $data): array
   {
      static $config_cache = [];
      $cache_key = md5(json_encode([
         $args['content'] ?? '',
         $args['field_name'] ?? ''
      ]));
      if (isset($config_cache[$cache_key])) {
         return $config_cache[$cache_key];
      }
      $config = [
         'allowed_types' => null,
         'max_size' => null,
         'convert' => 'webp',
         'quality' => 80,
         'create_thumbnails' => true,
      ];
      // Get field definition from registry if available
      if (!empty($args['content']) && !empty($args['field_name'])) {
         $content_type = $args['content'];
         $field_name = $args['field_name'];
         if (array_key_exists($content_type, JVB_CONTENT)) {
            $content_fields = JVB_CONTENT[$content_type]['fields'] ?? [];
            if (array_key_exists($field_name, $content_fields)) {
               $field_def = $content_fields[$field_name];
               // Extract relevant config from field definition
               $config = array_merge($config, [
                  'allowed_types' => $field_def['accepted_types'] ?? null,
                  'max_size' => $field_def['max_size'] ?? null,
                  'convert' => $field_def['convert'] ?? 'webp',
                  'quality' => $field_def['quality'] ?? 80,
                  'create_thumbnails' => $field_def['create_thumbnails'] ?? true,
               ]);
            }
         }
      }
      $config_cache[$cache_key] = $config;
      return $config;
   }
   /**
    * Get files info for error logging
    * @param array $files
    * @return array
    */
   protected function getFilesInfo(array $files): array
   {
@@ -1131,7 +641,7 @@
         $files = $request->get_file_params();
         $args = $this->buildUploadArgs($request);
         if (!array_key_exists('user', $args) || $args['user'] === 0){
         if (!$args['user']) {
            return $this->unauthorized();
         }
         if (!array_key_exists('content', $args) || empty($args['content'])) {
@@ -1157,7 +667,7 @@
            $chunkSize = 10;
         }
         JVB()->queue()->queueOperation(
         Queue::getInstance()->queueOperation(
            $operation_type,
            $args['user'],
            array_merge(
@@ -1171,7 +681,7 @@
            ]
         );
         $ID = JVB()->queue()->queueOperation(
         $ID = Queue::getInstance()->queueOperation(
            'process_upload_groups',
            $args['user'],
            $args,
@@ -1185,14 +695,8 @@
         return $this->queued($ID['operation_id'], 'Files uploaded and posts queued for creation');
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:handleGroupingRequest',
            $e->getMessage(),
            [
               'request_data' => $request->get_params(),
               'trace' => $e->getTraceAsString()
            ]
         );
         $this->logError($e->getMessage(), 'handleGroupingRequest');
         return $this->error('Grouping operation failed: '.$e->getMessage());
      }
   }