Now update the api.php to pass sort/filter params through. Let me find the contacts and threads action handlers:


294:        case 'contacts':
355:        case 'threads':


   294→        case 'contacts':
   295→            $page = max(1, (int)($_GET['page'] ?? 1));
   296→            $limit = min(100, max(1, (int)($_GET['limit'] ?? 20)));
   297→            $offset = ($page - 1) * $limit;
   298→            $search = $_GET['search'] ?? '';
   299→            $sort = $_GET['sort'] ?? 'name';
   300→            $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
   301→
   302→            // Convert wildcards: * and % to regex-friendly for QueryAdapter
   303→            $cleanSearch = $search;
   304→            if ($cleanSearch) {
   305→                // Strip wildcard chars — QueryAdapter already does LIKE %search% / regex
   306→                $cleanSearch = str_replace(['*', '%'], '', $cleanSearch);
   307→                $cleanSearch = trim($cleanSearch);
   308→            }
   309→
   310→            // Use QueryAdapter for automatic MySQL/MongoDB routing
   311→            $adapter = QueryAdapter::getInstance();
   312→            $result = $adapter->getContactsWithProfiles($cleanSearch, $limit, $offset, $sort, $order);
   313→
   314→            jsonResponse([
   315→                'contacts' => $result['contacts'] ?? [],
   316→                'pagination' => [
   317→                    'page' => $page,
   318→                    'limit' => $limit,
   319→                    'total' => (int)($result['total'] ?? 0),
   320→                    'pages' => ceil(($result['total'] ?? 0) / $limit)
   321→                ]
   322→            ]);
   323→            break;
   324→
   325→        // ==========================================
   326→        // Single Contact with Full Profile
   327→        // ==========================================
   328→        case 'contact':
   329→            $id = (int)($_GET['id'] ?? 0);
   330→            if (!$id) jsonError('Contact ID required');
   331→
   332→            // Use QueryAdapter for automatic MySQL/MongoDB routing
   333→            $adapter = QueryAdapter::getInstance();
   334→            $data = $adapter->getContactWithDetails($id);
   335→
   336→            if (!$data || !$data['contact']) {
   337→                jsonError('Contact not found', 404);
   338→            }
   339→
   340→            // Parse JSON fields (for MySQL string storage)
   341→            $jsonFields = ['personality_traits', 'detected_inconsistencies', 'risk_factors',
   342→                          'best_approach', 'topics_to_avoid', 'quote_history'];
   343→            foreach ($jsonFields as $field) {
   344→                if (isset($data['contact'][$field]) && is_string($data['contact'][$field])) {
   345→                    $data['contact'][$field] = json_decode($data['contact'][$field], true);
   346→                }
   347→            }
   348→
   349→            jsonResponse($data);
   350→            break;
   351→
   352→        // ==========================================
   353→        // Email Threads (grouped by thread_id)
   354→        // ==========================================
   355→        case 'threads':
   356→            $contactId = (int)($_GET['contact'] ?? 0);
   357→            $search = trim($_GET['search'] ?? '');
   358→            $page = max(1, (int)($_GET['page'] ?? 1));

        case 'contacts':
            $page = max(1, (int)($_GET['page'] ?? 1));
            $limit = min(100, max(1, (int)($_GET['limit'] ?? 20)));
            $offset = ($page - 1) * $limit;
            $search = $_GET['search'] ?? '';
            $sort = $_GET['sort'] ?? 'name';
            $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';

            // Convert wildcards: * and % to regex-friendly for QueryAdapter
            $cleanSearch = $search;
            if ($cleanSearch) {
                // Strip wildcard chars — QueryAdapter already does LIKE %search% / regex
                $cleanSearch = str_replace(['*', '%'], '', $cleanSearch);
                $cleanSearch = trim($cleanSearch);
            }

            // Use QueryAdapter for automatic MySQL/MongoDB routing
            $adapter = QueryAdapter::getInstance();
            $result = $adapter->getContactsWithProfiles($cleanSearch, $limit, $offset, $sort, $order);

            jsonResponse([
                'contacts' => $result['contacts'] ?? [],
                'pagination' => [
                    'page' => $page,
                    'limit' => $limit,
                    'total' => (int)($result['total'] ?? 0),
                    'pages' => ceil(($result['total'] ?? 0) / $limit)
                ]
            ]);
            break;

        // ==========================================
        // Single Contact with Full Profile
        // ==========================================
        case 'contact':
            $id = (int)($_GET['id'] ?? 0);
            if (!$id) jsonError('Contact ID required');

            // Use QueryAdapter for automatic MySQL/MongoDB routing
            $adapter = QueryAdapter::getInstance();
            $data = $adapter->getContactWithDetails($id);

            if (!$data || !$data['contact']) {
                jsonError('Contact not found', 404);
            }

            // Parse JSON fields (for MySQL string storage)
            $jsonFields = ['personality_traits', 'detected_inconsistencies', 'risk_factors',
                          'best_approach', 'topics_to_avoid', 'quote_history'];
            foreach ($jsonFields as $field) {
                if (isset($data['contact'][$field]) && is_string($data['contact'][$field])) {
                    $data['contact'][$field] = json_decode($data['contact'][$field], true);
                }
            }

            jsonResponse($data);
            break;

        // ==========================================
        // Email Threads (grouped by thread_id)
        // ==========================================
        case 'threads':
            $contactId = (int)($_GET['contact'] ?? 0);
            $search = trim($_GET['search'] ?? '');
            $page = max(1, (int)($_GET['page'] ?? 1));


   355→        case 'threads':
   356→            $contactId = (int)($_GET['contact'] ?? 0);
   357→            $search = trim($_GET['search'] ?? '');
   358→            $page = max(1, (int)($_GET['page'] ?? 1));
   359→            $limit = min(100, max(1, (int)($_GET['limit'] ?? 20)));
   360→            $offset = ($page - 1) * $limit;
   361→            $hasBusiness = isset($_GET['has_business']) && $_GET['has_business'];
   362→
   363→            // Use QueryAdapter for automatic MySQL/MongoDB routing
   364→            $adapter = QueryAdapter::getInstance();
   365→            $result = $adapter->getThreads(
   366→                $contactId ?: null,
   367→                $search,
   368→                $limit,
   369→                $offset
   370→            );
   371→
   372→            // Note: has_business filtering is handled by frontend due to raw_analysis being stored as JSON string
   373→            // Future: migrate raw_analysis to proper BSON objects for better querying
   374→
   375→            jsonResponse([
   376→                'threads' => $result['threads'],
   377→                'pagination' => [
   378→                    'page' => $page,
   379→                    'limit' => $limit,
   380→                    'total' => $result['total'],
   381→                    'pages' => ceil($result['total'] / $limit)
   382→                ]
   383→            ]);
   384→            break;
   385→
   386→        // ==========================================
   387→        // Single Thread with All Emails
   388→        // ==========================================
   389→        case 'thread':
   390→            $threadId = $_GET['thread_id'] ?? '';
   391→            if (!$threadId) jsonError('Thread ID required');
   392→
   393→            // Auto-resolve long URL thread ID to short hex ID from cache
   394→            $threadId = resolveThreadId($threadId);
   395→
   396→            // Use QueryAdapter for automatic MySQL/MongoDB routing
   397→            $adapter = QueryAdapter::getInstance();
   398→            $threadData = $adapter->getThreadDetails($threadId);
   399→
   400→            if (empty($threadData['emails'])) {
   401→                jsonError('Thread not found', 404);
   402→            }
   403→
   404→            $emails = $threadData['emails'];
   405→            $analysis = $threadData['analysis'];
   406→
   407→            // Parse JSON fields in analysis (for MySQL string storage)
   408→            if ($analysis && isset($analysis['raw_analysis']) && is_string($analysis['raw_analysis'])) {
   409→                // Strip markdown code block markers if present (```json ... ```)

        case 'threads':
            $contactId = (int)($_GET['contact'] ?? 0);
            $search = trim($_GET['search'] ?? '');
            $page = max(1, (int)($_GET['page'] ?? 1));
            $limit = min(100, max(1, (int)($_GET['limit'] ?? 20)));
            $offset = ($page - 1) * $limit;
            $hasBusiness = isset($_GET['has_business']) && $_GET['has_business'];

            // Use QueryAdapter for automatic MySQL/MongoDB routing
            $adapter = QueryAdapter::getInstance();
            $result = $adapter->getThreads(
                $contactId ?: null,
                $search,
                $limit,
                $offset
            );

            // Note: has_business filtering is handled by frontend due to raw_analysis being stored as JSON string
            // Future: migrate raw_analysis to proper BSON objects for better querying

            jsonResponse([
                'threads' => $result['threads'],
                'pagination' => [
                    'page' => $page,
                    'limit' => $limit,
                    'total' => $result['total'],
                    'pages' => ceil($result['total'] / $limit)
                ]
            ]);
            break;

        // ==========================================
        // Single Thread with All Emails
        // ==========================================
        case 'thread':
            $threadId = $_GET['thread_id'] ?? '';
            if (!$threadId) jsonError('Thread ID required');

            // Auto-resolve long URL thread ID to short hex ID from cache
            $threadId = resolveThreadId($threadId);

            // Use QueryAdapter for automatic MySQL/MongoDB routing
            $adapter = QueryAdapter::getInstance();
            $threadData = $adapter->getThreadDetails($threadId);

            if (empty($threadData['emails'])) {
                jsonError('Thread not found', 404);
            }

            $emails = $threadData['emails'];
            $analysis = $threadData['analysis'];

            // Parse JSON fields in analysis (for MySQL string storage)
            if ($analysis && isset($analysis['raw_analysis']) && is_string($analysis['raw_analysis'])) {
                // Strip markdown code block markers if present (```json ... ```)


Good. I can see the contacts endpoint already passes `$sort` and `$order` to `getContactsWithProfiles()`. For the threads endpoint, I need to add sort/filter params. Let me also add the date filter params to contacts:


Edit

            // Use QueryAdapter for automatic MySQL/MongoDB routing
            $adapter = QueryAdapter::getInstance();
            $result = $adapter->getContactsWithProfiles($cleanSearch, $limit, $offset, $sort, $order);

            jsonResponse([
                'contacts' => $result['contacts'] ?? [],

            $type = $_GET['type'] ?? '';
            $dateFrom = $_GET['date_from'] ?? '';
            $dateTo = $_GET['date_to'] ?? '';

            // Use QueryAdapter for automatic MySQL/MongoDB routing
            $adapter = QueryAdapter::getInstance();
            $result = $adapter->getContactsWithProfiles($cleanSearch, $limit, $offset, $sort, $order, $type, $dateFrom, $dateTo);

            jsonResponse([
                'contacts' => $result['contacts'] ?? [],