     1→<?php
     2→
     3→namespace EmailIntelligence;
     4→
     5→/**
     6→ * QueryAdapter - Automatically routes queries to MySQL or MongoDB
     7→ *
     8→ * This class provides a unified interface that works with both databases.
     9→ * When MySQL is enabled, it uses SQL queries.
    10→ * When MySQL is disabled, it routes to MongoRepository equivalents.
    11→ *
    12→ * Usage:
    13→ *   $adapter = QueryAdapter::getInstance();
    14→ *   $contacts = $adapter->getContactsWithProfiles($search, $limit, $offset, $sort);
    15→ */
    16→class QueryAdapter
    17→{
    18→    private static ?QueryAdapter $instance = null;
    19→    private Database $db;
    20→    private ?MongoRepository $mongoRepo = null;
    21→    private bool $useMongo = true;
    22→
    23→    private function __construct()
    24→    {
    25→        $this->db = Database::getInstance();
    26→
    27→        // Use MongoDB if MySQL is disabled
    28→        $this->useMongo = !defined('MYSQL_ENABLED') || !MYSQL_ENABLED;
    29→
    30→        if ($this->useMongo) {
    31→            $this->mongoRepo = new MongoRepository();
    32→        }
    33→    }
    34→
    35→    public static function getInstance(): QueryAdapter
    36→    {
    37→        if (self::$instance === null) {
    38→            self::$instance = new self();
    39→        }
    40→        return self::$instance;
    41→    }
    42→
    43→    /**
    44→     * Check which database is being used
    45→     */
    46→    public function getActiveDatabase(): string
    47→    {
    48→        return $this->useMongo ? 'mongodb' : 'mysql';
    49→    }
    50→
    51→    // =========================================================================
    52→    // CONTACTS
    53→    // =========================================================================
    54→
    55→    /**
    56→     * Get contacts with profiles (paginated)
    57→     *
    58→     * @param string $search Search term (name, email, company)
    59→     * @param int $limit Results per page
    60→     * @param int $offset Pagination offset
    61→     * @param string $sortColumn Column to sort by
    62→     * @param string $sortOrder ASC or DESC
    63→     * @return array ['contacts' => [...], 'total' => int]
    64→     */
    65→    public function getContactsWithProfiles(
    66→        string $search = '',
    67→        int $limit = 50,
    68→        int $offset = 0,
    69→        string $sortColumn = 'name',
    70→        string $sortOrder = 'ASC'
    71→    ): array {
    72→        if ($this->useMongo) {
    73→            return $this->getContactsWithProfilesMongo($search, $limit, $offset, $sortColumn, $sortOrder);
    74→        }
    75→
    76→        return $this->getContactsWithProfilesSQL($search, $limit, $offset, $sortColumn, $sortOrder);
    77→    }
    78→
    79→    private function getContactsWithProfilesSQL(
    80→        string $search,
    81→        int $limit,
    82→        int $offset,
    83→        string $sortColumn,
    84→        string $sortOrder
    85→    ): array {
    86→        $whereClause = '1=1';
    87→        $params = [];
    88→
    89→        if ($search) {
    90→            $whereClause = "(c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ?)";
    91→            $searchParam = "%{$search}%";
    92→            $params = [$searchParam, $searchParam, $searchParam];
    93→        }
    94→
    95→        // Validate sort column
    96→        $allowedColumns = ['name', 'email', 'company', 'created_at', 'reliability_score', 'risk_level'];
    97→        if (!in_array($sortColumn, $allowedColumns)) {
    98→            $sortColumn = 'name';
    99→        }
   100→        $sortOrder = strtoupper($sortOrder) === 'DESC' ? 'DESC' : 'ASC';
   101→
   102→        $total = $this->db->fetchColumn(
   103→            "SELECT COUNT(*) FROM contacts c WHERE {$whereClause}",
   104→            $params
   105→        );
   106→
   107→        $contacts = $this->db->fetchAll(
   108→            "SELECT c.*,
   109→                cp.relationship_health, cp.reliability_score, cp.risk_level,
   110→                cp.pricing_sensitivity, cp.ai_recommendations
   111→             FROM contacts c
   112→             LEFT JOIN contact_profiles cp ON c.id = cp.contact_id
   113→             WHERE {$whereClause}
   114→             ORDER BY {$sortColumn} {$sortOrder}
   115→             LIMIT ? OFFSET ?",
   116→            array_merge($params, [$limit, $offset])
   117→        );
   118→
   119→        return ['contacts' => $contacts, 'total' => (int)$total];
   120→    }
   121→
   122→    private function getContactsWithProfilesMongo(
   123→        string $search,
   124→        int $limit,
   125→        int $offset,
   126→        string $sortColumn,
   127→        string $sortOrder
   128→    ): array {
   129→        $filter = [];
   130→        if ($search) {
   131→            $filter = [
   132→                '$or' => [
   133→                    ['name' => ['$regex' => $search, '$options' => 'i']],
   134→                    ['email' => ['$regex' => $search, '$options' => 'i']],
   135→                    ['company' => ['$regex' => $search, '$options' => 'i']]
   136→                ]
   137→            ];
   138→        }
   139→
   140→        // Get total count
   141→        $mongo = MongoDatabase::getInstance();
   142→        $total = $mongo->count('contacts', $filter);
   143→
   144→        // Map sort column to MongoDB field
   145→        $sortDir = strtoupper($sortOrder) === 'DESC' ? -1 : 1;
   146→
   147→        // Get contacts with profiles via aggregation
   148→        $pipeline = [];
   149→
   150→        if (!empty($filter)) {
   151→            $pipeline[] = ['$match' => $filter];
   152→        }
   153→
   154→        $pipeline[] = ['$lookup' => [
   155→            'from' => 'contact_profiles',
   156→            'localField' => 'id',
   157→            'foreignField' => 'contact_id',
   158→            'as' => 'profile'
   159→        ]];
   160→
   161→        $pipeline[] = ['$unwind' => [
   162→            'path' => '$profile',
   163→            'preserveNullAndEmptyArrays' => true
   164→        ]];
   165→
   166→        $pipeline[] = ['$sort' => [$sortColumn => $sortDir]];
   167→        $pipeline[] = ['$skip' => $offset];
   168→        $pipeline[] = ['$limit' => $limit];
   169→
   170→        $results = $mongo->aggregate('contacts', $pipeline);
   171→
   172→        // Flatten profile fields
   173→        $contacts = array_map(function($doc) {
   174→            if (isset($doc['profile'])) {
   175→                $doc['relationship_health'] = $doc['profile']['relationship_health'] ?? null;
   176→                $doc['reliability_score'] = $doc['profile']['reliability_score'] ?? null;
   177→                $doc['risk_level'] = $doc['profile']['risk_level'] ?? null;
   178→                $doc['pricing_sensitivity'] = $doc['profile']['pricing_sensitivity'] ?? null;
   179→                $doc['ai_recommendations'] = $doc['profile']['ai_recommendations'] ?? null;
   180→                unset($doc['profile']);
   181→            }
   182→            return $doc;
   183→        }, $results);
   184→
   185→        return ['contacts' => $contacts, 'total' => $total];
   186→    }
   187→
   188→    /**
   189→     * Get single contact with full details
   190→     */
   191→    public function getContactWithDetails(int $contactId): ?array
   192→    {
   193→        if ($this->useMongo) {
   194→            return $this->getContactWithDetailsMongo($contactId);
   195→        }
   196→
   197→        return $this->getContactWithDetailsSQL($contactId);
   198→    }
   199→
   200→    private function getContactWithDetailsSQL(int $contactId): ?array
   201→    {
   202→        $contact = $this->db->fetchOne(
   203→            "SELECT c.*, cp.*
   204→             FROM contacts c
   205→             LEFT JOIN contact_profiles cp ON c.id = cp.contact_id
   206→             WHERE c.id = ?",
   207→            [$contactId]
   208→        );
   209→
   210→        if (!$contact) return null;
   211→
   212→        // Get recent emails
   213→        $recentEmails = $this->db->fetchAll(
   214→            "SELECT e.id, e.subject, e.date, e.direction, e.snippet,
   215→                    ea.sentiment_label, ea.urgency_level, ea.summary
   216→             FROM emails e
   217→             LEFT JOIN email_analysis ea ON e.id = ea.email_id
   218→             WHERE e.contact_id = ?
   219→             ORDER BY e.date DESC
   220→             LIMIT 20",
   221→            [$contactId]
   222→        );
   223→
   224→        // Get quotes
   225→        $quotes = $this->db->fetchAll(
   226→            "SELECT * FROM quotes WHERE contact_id = ? ORDER BY quote_date DESC",
   227→            [$contactId]
   228→        );
   229→
   230→        // Parse JSON fields
   231→        $jsonFields = ['personality_traits', 'detected_inconsistencies', 'risk_factors',
   232→                      'best_approach', 'topics_to_avoid', 'quote_history'];
   233→        foreach ($jsonFields as $field) {
   234→            if (isset($contact[$field]) && is_string($contact[$field])) {
   235→                $contact[$field] = json_decode($contact[$field], true);
   236→            }
   237→        }
   238→
   239→        return [
   240→            'contact' => $contact,
   241→            'recent_emails' => $recentEmails,
   242→            'quotes' => $quotes
   243→        ];
   244→    }
   245→
   246→    private function getContactWithDetailsMongo(int $contactId): ?array
   247→    {
   248→        $mongo = MongoDatabase::getInstance();
   249→
   250→        // Get contact with profile
   251→        $pipeline = [
   252→            ['$match' => ['id' => $contactId]],
   253→            ['$lookup' => [
   254→                'from' => 'contact_profiles',
   255→                'localField' => 'id',
   256→                'foreignField' => 'contact_id',
   257→                'as' => 'profile'
   258→            ]],
   259→            ['$unwind' => ['path' => '$profile', 'preserveNullAndEmptyArrays' => true]]
   260→        ];
   261→
   262→        $results = $mongo->aggregate('contacts', $pipeline);
   263→        if (empty($results)) return null;
   264→
   265→        $contact = $results[0];
   266→
   267→        // Flatten profile
   268→        if (isset($contact['profile'])) {
   269→            foreach ($contact['profile'] as $key => $value) {
   270→                if ($key !== '_id' && $key !== 'contact_id') {
   271→                    $contact[$key] = $value;
   272→                }
   273→            }
   274→            unset($contact['profile']);
   275→        }
   276→
   277→        // Get recent emails with analysis
   278→        $emailPipeline = [
   279→            ['$match' => ['contact_id' => $contactId]],
   280→            ['$lookup' => [
   281→                'from' => 'email_analysis',
   282→                'localField' => 'id',
   283→                'foreignField' => 'email_id',
   284→                'as' => 'analysis'
   285→            ]],
   286→            ['$unwind' => ['path' => '$analysis', 'preserveNullAndEmptyArrays' => true]],
   287→            ['$sort' => ['date' => -1]],
   288→            ['$limit' => 20],
   289→            ['$project' => [
   290→                'id' => 1,
   291→                'subject' => 1,
   292→                'date' => 1,
   293→                'direction' => 1,
   294→                'snippet' => 1,
   295→                'sentiment_label' => '$analysis.sentiment_label',
   296→                'urgency_level' => '$analysis.urgency_level',
   297→                'summary' => '$analysis.summary'
   298→            ]]
   299→        ];
   300→
   301→        $recentEmails = $mongo->aggregate('emails', $emailPipeline);
   302→
   303→        // Get quotes
   304→        $quotes = $mongo->findAll('quotes',
   305→            ['contact_id' => $contactId],
   306→            ['sort' => ['quote_date' => -1]]
   307→        );
   308→
   309→        return [
   310→            'contact' => $contact,
   311→            'recent_emails' => $recentEmails,
   312→            'quotes' => $quotes
   313→        ];
   314→    }
   315→
   316→    // =========================================================================
   317→    // EMAILS
   318→    // =========================================================================
   319→
   320→    /**
   321→     * Get emails with analysis for a contact
   322→     */
   323→    public function getEmailsWithAnalysis(
   324→        int $contactId,
   325→        int $limit = 50,
   326→        int $offset = 0,
   327→        string $sortColumn = 'date',
   328→        string $sortOrder = 'DESC'
   329→    ): array {
   330→        if ($this->useMongo) {
   331→            return $this->getEmailsWithAnalysisMongo($contactId, $limit, $offset, $sortColumn, $sortOrder);
   332→        }
   333→
   334→        return $this->getEmailsWithAnalysisSQL($contactId, $limit, $offset, $sortColumn, $sortOrder);
   335→    }
   336→
   337→    private function getEmailsWithAnalysisSQL(
   338→        int $contactId,
   339→        int $limit,
   340→        int $offset,
   341→        string $sortColumn,
   342→        string $sortOrder
   343→    ): array {
   344→        $total = $this->db->fetchColumn(
   345→            "SELECT COUNT(*) FROM emails WHERE contact_id = ?",
   346→            [$contactId]
   347→        );
   348→
   349→        $sortOrder = strtoupper($sortOrder) === 'DESC' ? 'DESC' : 'ASC';
   350→
   351→        $emails = $this->db->fetchAll(
   352→            "SELECT e.*, ea.sentiment_label, ea.urgency_level, ea.summary, ea.key_points
   353→             FROM emails e
   354→             LEFT JOIN email_analysis ea ON e.id = ea.email_id
   355→             WHERE e.contact_id = ?
   356→             ORDER BY e.{$sortColumn} {$sortOrder}
   357→             LIMIT ? OFFSET ?",
   358→            [$contactId, $limit, $offset]
   359→        );
   360→
   361→        return ['emails' => $emails, 'total' => (int)$total];
   362→    }
   363→
   364→    private function getEmailsWithAnalysisMongo(
   365→        int $contactId,
   366→        int $limit,
   367→        int $offset,
   368→        string $sortColumn,
   369→        string $sortOrder
   370→    ): array {
   371→        $mongo = MongoDatabase::getInstance();
   372→
   373→        $total = $mongo->count('emails', ['contact_id' => $contactId]);
   374→
   375→        $sortDir = strtoupper($sortOrder) === 'DESC' ? -1 : 1;
   376→
   377→        $pipeline = [
   378→            ['$match' => ['contact_id' => $contactId]],
   379→            ['$lookup' => [
   380→                'from' => 'email_analysis',
   381→                'localField' => 'id',
   382→                'foreignField' => 'email_id',
   383→                'as' => 'analysis'
   384→            ]],
   385→            ['$unwind' => ['path' => '$analysis', 'preserveNullAndEmptyArrays' => true]],
   386→            ['$sort' => [$sortColumn => $sortDir]],
   387→            ['$skip' => $offset],
   388→            ['$limit' => $limit]
   389→        ];
   390→
   391→        $results = $mongo->aggregate('emails', $pipeline);
   392→
   393→        // Flatten analysis
   394→        $emails = array_map(function($doc) {
   395→            if (isset($doc['analysis'])) {
   396→                $doc['sentiment_label'] = $doc['analysis']['sentiment_label'] ?? null;
   397→                $doc['urgency_level'] = $doc['analysis']['urgency_level'] ?? null;
   398→                $doc['summary'] = $doc['analysis']['summary'] ?? null;
   399→                $doc['key_points'] = $doc['analysis']['key_points'] ?? null;
   400→                unset($doc['analysis']);
   401→            }
   402→            return $doc;
   403→        }, $results);
   404→
   405→        return ['emails' => $emails, 'total' => $total];
   406→    }
   407→
   408→    /**
   409→     * Get single email with full details
   410→     */
   411→    public function getEmailWithDetails(int $emailId): ?array
   412→    {
   413→        if ($this->useMongo) {
   414→            $repo = new MongoRepository();
   415→            return $repo->getEmailWithDetails($emailId);
   416→        }
   417→
   418→        return $this->db->fetchOne(
   419→            "SELECT e.*, c.name as contact_name, c.email as contact_email,
   420→                    ea.*, ta.summary as thread_summary, ta.key_insights
   421→             FROM emails e
   422→             LEFT JOIN contacts c ON e.contact_id = c.id
   423→             LEFT JOIN email_analysis ea ON e.id = ea.email_id
   424→             LEFT JOIN thread_analysis ta ON e.thread_id = ta.thread_id
   425→             WHERE e.id = ?",
   426→            [$emailId]
   427→        );
   428→    }
   429→
   430→    /**
   431→     * Search emails
   432→     */
   433→    public function searchEmails(
   434→        string $query,
   435→        ?int $contactId = null,
   436→        int $limit = 50
   437→    ): array {
   438→        if ($this->useMongo) {
   439→            $repo = new MongoRepository();
   440→            return $repo->searchEmails($query, [
   441→                'contact_id' => $contactId,
   442→                'limit' => $limit
   443→            ]);
   444→        }
   445→
   446→        $whereClause = "(e.subject LIKE ? OR e.body_text LIKE ?)";
   447→        $params = ["%{$query}%", "%{$query}%"];
   448→
   449→        if ($contactId) {
   450→            $whereClause .= " AND e.contact_id = ?";
   451→            $params[] = $contactId;
   452→        }
   453→
   454→        return $this->db->fetchAll(
   455→            "SELECT e.*, c.name as contact_name, c.email as contact_email
   456→             FROM emails e
   457→             LEFT JOIN contacts c ON e.contact_id = c.id
   458→             WHERE {$whereClause}
   459→             ORDER BY e.date DESC
   460→             LIMIT ?",
   461→            array_merge($params, [$limit])
   462→        );
   463→    }
   464→
   465→    // =========================================================================
   466→    // THREADS
   467→    // =========================================================================
   468→
   469→    /**
   470→     * Get thread analysis with contact info
   471→     */
   472→    public function getThreadsWithContact(
   473→        ?int $contactId = null,
   474→        int $limit = 50
   475→    ): array {
   476→        if ($this->useMongo) {
   477→            $filter = $contactId ? ['contact_id' => $contactId] : [];
   478→            $repo = new MongoRepository();
   479→            return $repo->getThreadsWithContact($filter, ['limit' => $limit]);
   480→        }
   481→
   482→        $whereClause = $contactId ? "ta.contact_id = ?" : "1=1";
   483→        $params = $contactId ? [$contactId, $limit] : [$limit];
   484→
   485→        return $this->db->fetchAll(
   486→            "SELECT ta.*, c.name as contact_name, c.email as contact_email
   487→             FROM thread_analysis ta
   488→             LEFT JOIN contacts c ON ta.contact_id = c.id
   489→             WHERE {$whereClause}
   490→             ORDER BY ta.analyzed_at DESC
   491→             LIMIT ?",
   492→            $params
   493→        );
   494→    }
   495→
   496→    /**
   497→     * Get list of threads with summary information (paginated)
   498→     *
   499→     * @param int|null $contactId Filter by contact ID
   500→     * @param string $search Search term for subject
   501→     * @param int $limit Results per page
   502→     * @param int $offset Pagination offset
   503→     * @return array ['threads' => [...], 'total' => int]
   504→     */
   505→    public function getThreads(?int $contactId = null, string $search = '', int $limit = 20, int $offset = 0): array
   506→    {
   507→        if ($this->useMongo) {
   508→            return $this->getThreadsMongo($contactId, $search, $limit, $offset);
   509→        }
   510→
   511→        return $this->getThreadsSQL($contactId, $search, $limit, $offset);
   512→    }
   513→
   514→    private function getThreadsSQL(?int $contactId, string $search, int $limit, int $offset): array
   515→    {
   516→        $userEmail = defined('GMAIL_USER_EMAIL') ? strtolower(GMAIL_USER_EMAIL) : '';
   517→        $where = 'e.thread_id IS NOT NULL AND e.thread_id != ""';
   518→        $params = [];
   519→
   520→        if ($contactId) {
   521→            $where .= ' AND e.contact_id = ?';
   522→            $params[] = $contactId;
   523→        }
   524→
   525→        if ($search) {
   526→            $where .= ' AND e.subject LIKE ?';
   527→            $params[] = '%' . $search . '%';
   528→        }
   529→
   530→        // Get total thread count
   531→        $total = $this->db->fetchColumn(
   532→            "SELECT COUNT(DISTINCT e.thread_id)
   533→             FROM emails e
   534→             WHERE {$where}",
   535→            $params
   536→        );
   537→
   538→        // Get threads with summary info
   539→        // Use subquery for contact_id to find most frequent inbound sender (not MAX)
   540→        $threads = $this->db->fetchAll(
   541→            "SELECT
   542→                e.thread_id,
   543→                MIN(e.id) as first_email_id,
   544→                MAX(e.id) as last_email_id,
   545→                MIN(e.subject) as subject,
   546→                MIN(e.date) as first_date,
   547→                MAX(e.date) as last_date,
   548→                COUNT(*) as email_count,
   549→                SUM(CASE WHEN e.direction = 'inbound' THEN 1 ELSE 0 END) as inbound_count,
   550→                SUM(CASE WHEN e.direction = 'outbound' THEN 1 ELSE 0 END) as outbound_count,
   551→                (SELECT e2.contact_id FROM emails e2
   552→                 WHERE e2.thread_id = e.thread_id
   553→                 AND e2.direction = 'inbound'
   554→                 AND LOWER(e2.from_email) != ?
   555→                 GROUP BY e2.contact_id
   556→                 ORDER BY COUNT(*) DESC LIMIT 1) as contact_id,
   557→                (SELECT c2.name FROM emails e2
   558→                 JOIN contacts c2 ON e2.contact_id = c2.id
   559→                 WHERE e2.thread_id = e.thread_id
   560→                 AND e2.direction = 'inbound'
   561→                 AND LOWER(e2.from_email) != ?
   562→                 GROUP BY e2.contact_id, c2.name
   563→                 ORDER BY COUNT(*) DESC LIMIT 1) as contact_name,
   564→                ta.is_analyzed,
   565→                ta.needs_reanalysis
   566→             FROM emails e
   567→             LEFT JOIN thread_analysis ta ON e.thread_id = ta.thread_id
   568→             WHERE {$where}
   569→             GROUP BY e.thread_id
   570→             ORDER BY MAX(e.date) DESC
   571→             LIMIT ? OFFSET ?",
   572→            array_merge([$userEmail, $userEmail], $params, [$limit, $offset])
   573→        );
   574→
   575→        return [
   576→            'threads' => $threads,
   577→            'total' => (int)$total
   578→        ];
   579→    }
   580→
   581→    private function getThreadsMongo(?int $contactId, string $search, int $limit, int $offset): array
   582→    {
   583→        $mongo = MongoDatabase::getInstance();
   584→        $userEmail = defined('GMAIL_USER_EMAIL') ? strtolower(GMAIL_USER_EMAIL) : '';
   585→
   586→        // Build match filter
   587→        $matchFilter = [
   588→            'thread_id' => ['$ne' => null, '$ne' => '']
   589→        ];
   590→
   591→        if ($contactId) {
   592→            $matchFilter['contact_id'] = $contactId;
   593→        }
   594→
   595→        if ($search) {
   596→            $matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];
   597→        }
   598→
   599→        // Count distinct threads for total
   600→        $countPipeline = [
   601→            ['$match' => $matchFilter],
   602→            ['$group' => ['_id' => '$thread_id']],
   603→            ['$count' => 'total']
   604→        ];
   605→
   606→        $countResult = $mongo->aggregate('emails', $countPipeline);
   607→        $total = !empty($countResult) ? $countResult[0]['total'] : 0;
   608→
   609→        // Get threads with aggregated data
   610→        // Collect all contacts per thread to find the most relevant one (not just $max)
   611→        $pipeline = [
   612→            ['$match' => $matchFilter],
   613→            ['$group' => [
   614→                '_id' => '$thread_id',
   615→                'first_email_id' => ['$min' => '$id'],
   616→                'last_email_id' => ['$max' => '$id'],
   617→                'subject' => ['$first' => '$subject'],
   618→                'first_date' => ['$min' => '$date'],
   619→                'last_date' => ['$max' => '$date'],
   620→                'email_count' => ['$sum' => 1],
   621→                'inbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'inbound']], 1, 0]]],
   622→                'outbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'outbound']], 1, 0]]],
   623→                'all_contacts' => ['$push' => [
   624→                    'cid' => '$contact_id',
   625→                    'email' => '$from_email',
   626→                    'dir' => '$direction'
   627→                ]]
   628→            ]],
   629→            ['$lookup' => [
   630→                'from' => 'thread_analysis',
   631→                'localField' => '_id',
   632→                'foreignField' => 'thread_id',
   633→                'as' => 'analysis'
   634→            ]],
   635→            ['$project' => [
   636→                '_id' => 0,
   637→                'thread_id' => '$_id',
   638→                'first_email_id' => 1,
   639→                'last_email_id' => 1,
   640→                'subject' => 1,
   641→                'first_date' => 1,
   642→                'last_date' => 1,
   643→                'email_count' => 1,
   644→                'inbound_count' => 1,
   645→                'outbound_count' => 1,
   646→                'all_contacts' => 1,
   647→                'is_analyzed' => ['$arrayElemAt' => ['$analysis.is_analyzed', 0]],
   648→                'needs_reanalysis' => ['$arrayElemAt' => ['$analysis.needs_reanalysis', 0]]
   649→            ]],
   650→            ['$sort' => ['last_date' => -1]],
   651→            ['$skip' => $offset],
   652→            ['$limit' => $limit]
   653→        ];
   654→
   655→        $threads = $mongo->aggregate('emails', $pipeline);
   656→
   657→        // Post-process: find the most frequent external contact per thread
   658→        $contactIds = [];
   659→        foreach ($threads as &$thread) {
   660→            $contactCounts = [];
   661→
   662→            foreach ($thread['all_contacts'] ?? [] as $c) {
   663→                $cid = $c['cid'] ?? null;
   664→                $email = strtolower($c['email'] ?? '');
   665→                $dir = $c['dir'] ?? '';
   666→
   667→                // Skip user's own emails and null contacts
   668→                if (!$cid || $email === $userEmail) continue;
   669→
   670→                // Weight inbound contacts higher (they're the external sender)
   671→                if ($dir === 'inbound') {
   672→                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 2;
   673→                } else {
   674→                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 1;
   675→                }
   676→            }
   677→
   678→            // Pick the most frequent contact
   679→            if (!empty($contactCounts)) {
   680→                arsort($contactCounts);
   681→                $thread['contact_id'] = (int)array_key_first($contactCounts);
   682→            } else {
   683→                // Fallback: first non-null contact_id
   684→                $thread['contact_id'] = null;
   685→                foreach ($thread['all_contacts'] ?? [] as $c) {
   686→                    if (!empty($c['cid'])) {
   687→                        $thread['contact_id'] = (int)$c['cid'];
   688→                        break;
   689→                    }
   690→                }
   691→            }
   692→
   693→            if ($thread['contact_id']) {
   694→                $contactIds[] = $thread['contact_id'];
   695→            }
   696→
   697→            unset($thread['all_contacts']);
   698→        }
   699→        unset($thread);
   700→
   701→        // Batch lookup contact names
   702→        $contactNames = [];
   703→        if (!empty($contactIds)) {
   704→            $contacts = $mongo->findAll('contacts', [
   705→                'id' => ['$in' => array_values(array_unique($contactIds))]
   706→            ]);
   707→            foreach ($contacts as $c) {
   708→                $contactNames[$c['id']] = $c['name'] ?? '';
   709→            }
   710→        }
   711→
   712→        // Assign contact names
   713→        foreach ($threads as &$thread) {
   714→            $thread['contact_name'] = $contactNames[$thread['contact_id'] ?? 0] ?? null;
   715→        }
   716→        unset($thread);
   717→
   718→        return [
   719→            'threads' => $threads,
   720→            'total' => $total
   721→        ];
   722→    }
   723→
   724→    /**
   725→     * Get full thread details with all emails and analysis
   726→     *
   727→     * @param string $threadId Gmail thread ID
   728→     * @return array ['thread_id', 'emails', 'analysis', 'email_count']
   729→     */
   730→    public function getThreadDetails(string $threadId): array
   731→    {
   732→        if ($this->useMongo) {
   733→            return $this->getThreadDetailsMongo($threadId);
   734→        }
   735→
   736→        return $this->getThreadDetailsSQL($threadId);
   737→    }
   738→
   739→    private function getThreadDetailsSQL(string $threadId): array
   740→    {
   741→        // Get all emails in thread with contact info
   742→        $emails = $this->db->fetchAll(
   743→            "SELECT e.*, c.name as contact_name, c.email as contact_email
   744→             FROM emails e
   745→             LEFT JOIN contacts c ON e.contact_id = c.id
   746→             WHERE e.thread_id = ?
   747→             ORDER BY e.date ASC",
   748→            [$threadId]
   749→        );
   750→
   751→        // Get thread analysis with email analysis
   752→        $analysis = $this->db->fetchOne(
   753→            "SELECT ta.*, ea.summary, ea.sentiment_label, ea.deception_score,
   754→                    ea.raw_analysis, ea.key_points
   755→             FROM thread_analysis ta
   756→             LEFT JOIN email_analysis ea ON ta.last_email_id = ea.email_id
   757→             WHERE ta.thread_id = ?",
   758→            [$threadId]
   759→        );
   760→
   761→        return [
   762→            'thread_id' => $threadId,
   763→            'emails' => $emails,
   764→            'email_count' => count($emails),
   765→            'analysis' => $analysis
   766→        ];
   767→    }
   768→
   769→    private function getThreadDetailsMongo(string $threadId): array
   770→    {
   771→        $mongo = MongoDatabase::getInstance();
   772→
   773→        // Get all emails in thread with contact info using aggregation
   774→        $emailPipeline = [
   775→            ['$match' => ['thread_id' => $threadId]],
   776→            ['$lookup' => [
   777→                'from' => 'contacts',
   778→                'localField' => 'contact_id',
   779→                'foreignField' => 'id',
   780→                'as' => 'contact'
   781→            ]],
   782→            ['$unwind' => ['path' => '$contact', 'preserveNullAndEmptyArrays' => true]],
   783→            ['$sort' => ['date' => 1]],  // ASC
   784→            ['$project' => [
   785→                '_id' => 0,
   786→                'id' => 1,
   787→                'gmail_id' => 1,
   788→                'thread_id' => 1,
   789→                'contact_id' => 1,
   790→                'subject' => 1,
   791→                'from_email' => 1,
   792→                'to_email' => 1,
   793→                'date' => 1,
   794→                'direction' => 1,
   795→                'snippet' => 1,
   796→                'body_text' => 1,
   797→                'body_html' => 1,
   798→                'has_attachments' => 1,
   799→                'label_ids' => 1,
   800→                'contact_name' => '$contact.name',
   801→                'contact_email' => '$contact.email'
   802→            ]]
   803→        ];
   804→
   805→        $emails = $mongo->aggregate('emails', $emailPipeline);
   806→
   807→        // Get thread analysis with email analysis
   808→        $analysisPipeline = [
   809→            ['$match' => ['thread_id' => $threadId]],
   810→            ['$lookup' => [
   811→                'from' => 'email_analysis',
   812→                'localField' => 'last_email_id',
   813→                'foreignField' => 'email_id',
   814→                'as' => 'email_analysis'
   815→            ]],
   816→            ['$unwind' => ['path' => '$email_analysis', 'preserveNullAndEmptyArrays' => true]],
   817→            ['$project' => [
   818→                '_id' => 0,
   819→                'thread_id' => 1,
   820→                'contact_id' => 1,
   821→                'subject' => 1,
   822→                'email_count' => 1,
   823→                'first_date' => 1,
   824→                'last_date' => 1,
   825→                'last_email_id' => 1,
   826→                'trust_score' => 1,
   827→                'deception_score' => 1,
   828→                'risk_level' => 1,
   829→                'summary' => ['$ifNull' => ['$email_analysis.summary', null]],
   830→                'sentiment_label' => ['$ifNull' => ['$email_analysis.sentiment_label', null]],
   831→                'raw_analysis' => 1,
   832→                'key_insights' => 1,
   833→                'key_points' => ['$ifNull' => ['$email_analysis.key_points', null]],
   834→                'action_items' => 1,
   835→                'warnings' => 1,
   836→                'analyzed_at' => 1,
   837→                'tokens_used' => 1
   838→            ]],
   839→            ['$limit' => 1]
   840→        ];
   841→
   842→        $analysisResults = $mongo->aggregate('thread_analysis', $analysisPipeline);
   843→        $analysis = !empty($analysisResults) ? $analysisResults[0] : null;
   844→
   845→        return [
   846→            'thread_id' => $threadId,
   847→            'emails' => $emails,
   848→            'email_count' => count($emails),
   849→            'analysis' => $analysis
   850→        ];
   851→    }
   852→
   853→    /**
   854→     * Get thread participants with aggregated statistics
   855→     *
   856→     * @param string $threadId Gmail thread ID
   857→     * @param string $userEmail Current user's email for 'self' detection
   858→     * @return array ['participants' => [...], 'subject' => string]
   859→     */
   860→    public function getThreadParticipants(string $threadId, string $userEmail): array
   861→    {
   862→
   863→        if ($this->useMongo) {
   864→            return $this->getThreadParticipantsMongo($threadId, $userEmail);
   865→        }
   866→
   867→        return $this->getThreadParticipantsSQL($threadId, $userEmail);
   868→    }
   869→
   870→    private function getThreadParticipantsSQL(string $threadId, string $userEmail): array
   871→    {
   872→        $userEmail = strtolower($userEmail);
   873→
   874→        // Fetch ALL emails in the thread to extract participants from FROM, TO, and CC fields
   875→        $emails = $this->db->fetchAll(
   876→            "SELECT from_email, to_email, cc_email, date, direction, subject
   877→             FROM emails
   878→             WHERE thread_id = ?
   879→             ORDER BY date ASC",
   880→            [$threadId]
   881→        );
   882→
   883→        // Collect all unique participants with their stats
   884→        $participantStats = [];
   885→        $threadSubject = '';
   886→
   887→        foreach ($emails as $email) {
   888→            // Get subject from first email
   889→            if (empty($threadSubject) && !empty($email['subject'])) {
   890→                $threadSubject = $email['subject'];
   891→            }
   892→
   893→            $date = $email['date'] ?? null;
   894→            $direction = $email['direction'] ?? '';
   895→
   896→            // Extract FROM participant
   897→            if (!empty($email['from_email'])) {
   898→                $fromEmail = strtolower(trim($email['from_email']));
   899→                if (!isset($participantStats[$fromEmail])) {
   900→                    $participantStats[$fromEmail] = [
   901→                        'email' => $fromEmail,
   902→                        'sent_count' => 0,
   903→                        'received_count' => 0,
   904→                        'total_count' => 0,
   905→                        'first_email' => $date,
   906→                        'last_email' => $date
   907→                    ];
   908→                }
   909→                $participantStats[$fromEmail]['sent_count']++;
   910→                $participantStats[$fromEmail]['total_count']++;
   911→                if ($date) {
   912→                    if (!$participantStats[$fromEmail]['first_email'] || $date < $participantStats[$fromEmail]['first_email']) {
   913→                        $participantStats[$fromEmail]['first_email'] = $date;
   914→                    }
   915→                    if (!$participantStats[$fromEmail]['last_email'] || $date > $participantStats[$fromEmail]['last_email']) {
   916→                        $participantStats[$fromEmail]['last_email'] = $date;
   917→                    }
   918→                }
   919→            }
   920→
   921→            // Extract TO participant(s)
   922→            if (!empty($email['to_email'])) {
   923→                $toEmail = strtolower(trim($email['to_email']));
   924→                if (!isset($participantStats[$toEmail])) {
   925→                    $participantStats[$toEmail] = [
   926→                        'email' => $toEmail,
   927→                        'sent_count' => 0,
   928→                        'received_count' => 0,
   929→                        'total_count' => 0,
   930→                        'first_email' => $date,
   931→                        'last_email' => $date
   932→                    ];
   933→                }
   934→                $participantStats[$toEmail]['received_count']++;
   935→                $participantStats[$toEmail]['total_count']++;
   936→                if ($date) {
   937→                    if (!$participantStats[$toEmail]['first_email'] || $date < $participantStats[$toEmail]['first_email']) {
   938→                        $participantStats[$toEmail]['first_email'] = $date;
   939→                    }
   940→                    if (!$participantStats[$toEmail]['last_email'] || $date > $participantStats[$toEmail]['last_email']) {
   941→                        $participantStats[$toEmail]['last_email'] = $date;
   942→                    }
   943→                }
   944→            }
   945→
   946→            // Extract CC participant(s)
   947→            if (!empty($email['cc_email'])) {
   948→                // CC might be comma-separated, split it
   949→                $ccEmails = array_map('trim', explode(',', $email['cc_email']));
   950→                foreach ($ccEmails as $ccEmail) {
   951→                    $ccEmail = strtolower($ccEmail);
   952→                    if (empty($ccEmail)) continue;
   953→
   954→                    if (!isset($participantStats[$ccEmail])) {
   955→                        $participantStats[$ccEmail] = [
   956→                            'email' => $ccEmail,
   957→                            'sent_count' => 0,
   958→                            'received_count' => 0,
   959→                            'total_count' => 0,
   960→                            'first_email' => $date,
   961→                            'last_email' => $date
   962→                        ];
   963→                    }
   964→                    $participantStats[$ccEmail]['received_count']++;
   965→                    $participantStats[$ccEmail]['total_count']++;
   966→                    if ($date) {
   967→                        if (!$participantStats[$ccEmail]['first_email'] || $date < $participantStats[$ccEmail]['first_email']) {
   968→                            $participantStats[$ccEmail]['first_email'] = $date;
   969→                        }
   970→                        if (!$participantStats[$ccEmail]['last_email'] || $date > $participantStats[$ccEmail]['last_email']) {
   971→                            $participantStats[$ccEmail]['last_email'] = $date;
   972→                        }
   973→                    }
   974→                }
   975→            }
   976→        }
   977→
   978→        // Convert to array and sort by total_count
   979→        $participants = array_values($participantStats);
   980→        usort($participants, function($a, $b) {
   981→            return $b['total_count'] - $a['total_count'];
   982→        });
   983→
   984→        // Enrich with contact data and determine participant type
   985→        foreach ($participants as &$p) {
   986→            // Determine participant type
   987→            $p['participant_type'] = (strtolower($p['email']) === $userEmail) ? 'self' : 'external';
   988→
   989→            // Calculate inbound/outbound based on direction and participant type
   990→            if ($p['participant_type'] === 'self') {
   991→                $p['inbound_count'] = $p['received_count'];
   992→                $p['outbound_count'] = $p['sent_count'];
   993→            } else {
   994→                $p['inbound_count'] = $p['sent_count'];  // External sent TO us = inbound
   995→                $p['outbound_count'] = $p['received_count'];  // We sent TO external = outbound (for us)
   996→            }
   997→
   998→            $p['email_count'] = $p['total_count'];
   999→
  1000→            // Get name guess from email
  1001→            $emailParts = explode('@', $p['email']);
  1002→            $namePart = $emailParts[0] ?? '';
  1003→            $nameGuess = ucfirst(explode('.', $namePart)[0]);
  1004→
  1005→            // Fetch contact data from contacts table
  1006→            $contact = $this->db->fetchOne(
  1007→                "SELECT id, name, email, company, type FROM contacts WHERE email = ?",
  1008→                [$p['email']]
  1009→            );
  1010→
  1011→            // Fetch enrichment data from contact_enrichment table
  1012→            $enrichment = null;
  1013→            if ($contact && !empty($contact['id'])) {
  1014→                $enrichment = $this->db->fetchOne(
  1015→                    "SELECT job_title, phone, mobile_phone, linkedin_url
  1016→                     FROM contact_enrichment
  1017→                     WHERE contact_id = ?",
  1018→                    [$contact['id']]
  1019→                );
  1020→            }
  1021→
  1022→            if ($p['participant_type'] === 'external') {
  1023→                if ($contact) {
  1024→                    $p['contact_id'] = $contact['id'];
  1025→                    $p['name'] = $contact['name'] ?? $nameGuess;
  1026→                    $p['company'] = $contact['company'] ?? '';
  1027→                    $p['contact_type'] = $contact['type'] ?? null;
  1028→
  1029→                    // Add enriched data
  1030→                    if ($enrichment) {
  1031→                        $p['job_title'] = $enrichment['job_title'] ?? null;
  1032→                        $p['phone'] = $enrichment['phone'] ?? $enrichment['mobile_phone'] ?? null;
  1033→                        $p['linkedin_url'] = $enrichment['linkedin_url'] ?? null;
  1034→                    }
  1035→                } else {
  1036→                    $p['contact_id'] = null;
  1037→                    $p['name'] = $nameGuess;
  1038→                    $p['company'] = $emailParts[1] ?? '';
  1039→                    $p['contact_type'] = null;
  1040→                }
  1041→            } else {
  1042→                $p['name'] = 'You';
  1043→                $p['company'] = '';
  1044→                $p['contact_id'] = $contact ? $contact['id'] : null;
  1045→                $p['contact_type'] = null;
  1046→            }
  1047→
  1048→            // Remove temporary fields
  1049→            unset($p['sent_count'], $p['received_count'], $p['total_count']);
  1050→        }
  1051→
  1052→        return [
  1053→            'participants' => $participants,
  1054→            'subject' => $threadSubject
  1055→        ];
  1056→    }
  1057→
  1058→    private function getThreadParticipantsMongo(string $threadId, string $userEmail): array
  1059→    {
  1060→
  1061→        $mongo = MongoDatabase::getInstance();
  1062→        $userEmail = strtolower($userEmail);
  1063→
  1064→        // Fetch ALL emails in the thread to extract participants from FROM, TO, and CC fields
  1065→        $emails = $mongo->findAll('emails', ['thread_id' => $threadId]);
  1066→
  1067→        // Collect all unique participants with their stats
  1068→        $participantStats = [];
  1069→        $threadSubject = '';
  1070→
  1071→        foreach ($emails as $email) {
  1072→            // Get subject from first email
  1073→            if (empty($threadSubject) && !empty($email['subject'])) {
  1074→                $threadSubject = $email['subject'];
  1075→            }
  1076→
  1077→            $date = $email['date'] ?? null;
  1078→            $direction = $email['direction'] ?? '';
  1079→
  1080→            // Extract FROM participant
  1081→            if (!empty($email['from_email'])) {
  1082→                $fromEmail = strtolower(trim($email['from_email']));
  1083→                if (!isset($participantStats[$fromEmail])) {
  1084→                    $participantStats[$fromEmail] = [
  1085→                        'email' => $fromEmail,
  1086→                        'sent_count' => 0,
  1087→                        'received_count' => 0,
  1088→                        'total_count' => 0,
  1089→                        'first_email' => $date,
  1090→                        'last_email' => $date
  1091→                    ];
  1092→                }
  1093→                $participantStats[$fromEmail]['sent_count']++;
  1094→                $participantStats[$fromEmail]['total_count']++;
  1095→                if ($date) {
  1096→                    if (!$participantStats[$fromEmail]['first_email'] || $date < $participantStats[$fromEmail]['first_email']) {
  1097→                        $participantStats[$fromEmail]['first_email'] = $date;
  1098→                    }
  1099→                    if (!$participantStats[$fromEmail]['last_email'] || $date > $participantStats[$fromEmail]['last_email']) {
  1100→                        $participantStats[$fromEmail]['last_email'] = $date;
  1101→                    }
  1102→                }
  1103→            }
  1104→
  1105→            // Extract TO participant(s)
  1106→            if (!empty($email['to_email'])) {
  1107→                $toEmail = strtolower(trim($email['to_email']));
  1108→                if (!isset($participantStats[$toEmail])) {
  1109→                    $participantStats[$toEmail] = [
  1110→                        'email' => $toEmail,
  1111→                        'sent_count' => 0,
  1112→                        'received_count' => 0,
  1113→                        'total_count' => 0,
  1114→                        'first_email' => $date,
  1115→                        'last_email' => $date
  1116→                    ];
  1117→                }
  1118→                $participantStats[$toEmail]['received_count']++;
  1119→                $participantStats[$toEmail]['total_count']++;
  1120→                if ($date) {
  1121→                    if (!$participantStats[$toEmail]['first_email'] || $date < $participantStats[$toEmail]['first_email']) {
  1122→                        $participantStats[$toEmail]['first_email'] = $date;
  1123→                    }
  1124→                    if (!$participantStats[$toEmail]['last_email'] || $date > $participantStats[$toEmail]['last_email']) {
  1125→                        $participantStats[$toEmail]['last_email'] = $date;
  1126→                    }
  1127→                }
  1128→            }
  1129→
  1130→            // Extract CC participant(s)
  1131→            if (!empty($email['cc_email'])) {
  1132→                // CC might be comma-separated, split it
  1133→                $ccEmails = array_map('trim', explode(',', $email['cc_email']));
  1134→                foreach ($ccEmails as $ccEmail) {
  1135→                    $ccEmail = strtolower($ccEmail);
  1136→                    if (empty($ccEmail)) continue;
  1137→
  1138→                    if (!isset($participantStats[$ccEmail])) {
  1139→                        $participantStats[$ccEmail] = [
  1140→                            'email' => $ccEmail,
  1141→                            'sent_count' => 0,
  1142→                            'received_count' => 0,
  1143→                            'total_count' => 0,
  1144→                            'first_email' => $date,
  1145→                            'last_email' => $date
  1146→                        ];
  1147→                    }
  1148→                    $participantStats[$ccEmail]['received_count']++;
  1149→                    $participantStats[$ccEmail]['total_count']++;
  1150→                    if ($date) {
  1151→                        if (!$participantStats[$ccEmail]['first_email'] || $date < $participantStats[$ccEmail]['first_email']) {
  1152→                            $participantStats[$ccEmail]['first_email'] = $date;
  1153→                        }
  1154→                        if (!$participantStats[$ccEmail]['last_email'] || $date > $participantStats[$ccEmail]['last_email']) {
  1155→                            $participantStats[$ccEmail]['last_email'] = $date;
  1156→                        }
  1157→                    }
  1158→                }
  1159→            }
  1160→        }
  1161→
  1162→        // Convert to array and sort by total_count
  1163→        $participants = array_values($participantStats);
  1164→        usort($participants, function($a, $b) {
  1165→            return $b['total_count'] - $a['total_count'];
  1166→        });
  1167→
  1168→        // Enrich with contact data and determine participant type
  1169→        foreach ($participants as &$p) {
  1170→            // Determine participant type
  1171→            $p['participant_type'] = (strtolower($p['email']) === $userEmail) ? 'self' : 'external';
  1172→
  1173→            // Calculate inbound/outbound based on direction and participant type
  1174→            if ($p['participant_type'] === 'self') {
  1175→                $p['inbound_count'] = $p['received_count'];
  1176→                $p['outbound_count'] = $p['sent_count'];
  1177→            } else {
  1178→                $p['inbound_count'] = $p['sent_count'];  // External sent TO us = inbound
  1179→                $p['outbound_count'] = $p['received_count'];  // We sent TO external = outbound (for us)
  1180→            }
  1181→
  1182→            $p['email_count'] = $p['total_count'];
  1183→
  1184→            // Get name guess from email
  1185→            $emailParts = explode('@', $p['email']);
  1186→            $namePart = $emailParts[0] ?? '';
  1187→            $nameGuess = ucfirst(explode('.', $namePart)[0]);
  1188→
  1189→            // Fetch contact data from contacts collection
  1190→            $contact = $mongo->findOne('contacts', ['email' => $p['email']]);
  1191→
  1192→            // Fetch enrichment data from contact_enrichment collection
  1193→            $enrichment = null;
  1194→            if ($contact && !empty($contact['id'])) {
  1195→                $enrichment = $mongo->findOne('contact_enrichment', ['contact_id' => $contact['id']]);
  1196→            }
  1197→
  1198→            if ($p['participant_type'] === 'external') {
  1199→                if ($contact) {
  1200→                    $p['contact_id'] = $contact['id'] ?? null;
  1201→                    $p['name'] = $contact['name'] ?? $nameGuess;
  1202→                    $p['company'] = $contact['company'] ?? '';
  1203→                    $p['contact_type'] = $contact['type'] ?? null;
  1204→
  1205→                    // Add enriched data
  1206→                    if ($enrichment) {
  1207→                        $p['job_title'] = $enrichment['job_title'] ?? null;
  1208→                        $p['phone'] = $enrichment['phone'] ?? $enrichment['mobile_phone'] ?? null;
  1209→                        $p['linkedin_url'] = $enrichment['linkedin_url'] ?? null;
  1210→                    }
  1211→                } else {
  1212→                    $p['contact_id'] = null;
  1213→                    $p['name'] = $nameGuess;
  1214→                    $p['company'] = $emailParts[1] ?? '';
  1215→                    $p['contact_type'] = null;
  1216→                }
  1217→            } else {
  1218→                $p['name'] = 'You';
  1219→                $p['company'] = '';
  1220→                $p['contact_id'] = $contact['id'] ?? null;
  1221→                $p['contact_type'] = null;
  1222→            }
  1223→
  1224→            // Remove temporary fields
  1225→            unset($p['sent_count'], $p['received_count'], $p['total_count']);
  1226→        }
  1227→
  1228→        return [
  1229→            'participants' => $participants,
  1230→            'subject' => $threadSubject
  1231→        ];
  1232→    }
  1233→
  1234→    // =========================================================================
  1235→    // LOGS
  1236→    // =========================================================================
  1237→
  1238→    /**
  1239→     * Get API logs with optional filtering
  1240→     *
  1241→     * @param string|null $threadId Filter by thread ID
  1242→     * @param string|null $level Filter by log level (info, warning, error)
  1243→     * @param int $limit Maximum number of logs to return
  1244→     * @return array ['logs' => [...], 'count' => int]
  1245→     */
  1246→    public function getLogs(?string $threadId = null, ?string $level = null, int $limit = 50): array
  1247→    {
  1248→        if ($this->useMongo) {
  1249→            return $this->getLogsMongo($threadId, $level, $limit);
  1250→        }
  1251→
  1252→        return $this->getLogsSQL($threadId, $level, $limit);
  1253→    }
  1254→
  1255→    private function getLogsSQL(?string $threadId, ?string $level, int $limit): array
  1256→    {
  1257→        $where = '1=1';
  1258→        $params = [];
  1259→
  1260→        if ($threadId) {
  1261→            $where .= ' AND thread_id = ?';
  1262→            $params[] = $threadId;
  1263→        }
  1264→        if ($level) {
  1265→            $where .= ' AND log_level = ?';
  1266→            $params[] = $level;
  1267→        }
  1268→
  1269→        $logs = $this->db->fetchAll(
  1270→            "SELECT id, log_level, source, message, context, thread_id, created_at
  1271→             FROM api_logs
  1272→             WHERE {$where}
  1273→             ORDER BY created_at DESC
  1274→             LIMIT ?",
  1275→            array_merge($params, [$limit])
  1276→        );
  1277→
  1278→        // Parse context JSON (MySQL stores as string)
  1279→        foreach ($logs as &$log) {
  1280→            if (isset($log['context']) && is_string($log['context'])) {
  1281→                $log['context'] = json_decode($log['context'], true);
  1282→            }
  1283→        }
  1284→
  1285→        return [
  1286→            'logs' => $logs,
  1287→            'count' => count($logs)
  1288→        ];
  1289→    }
  1290→
  1291→    private function getLogsMongo(?string $threadId, ?string $level, int $limit): array
  1292→    {
  1293→        $mongo = MongoDatabase::getInstance();
  1294→
  1295→        $filter = [];
  1296→
  1297→        if ($threadId) {
  1298→            $filter['thread_id'] = $threadId;
  1299→        }
  1300→        if ($level) {
  1301→            $filter['log_level'] = $level;
  1302→        }
  1303→
  1304→        $logs = $mongo->findAll('api_logs', $filter, [
  1305→            'sort' => ['created_at' => -1],
  1306→            'limit' => $limit
  1307→        ]);
  1308→
  1309→        // Context should already be an array in MongoDB, but handle string case for safety
  1310→        foreach ($logs as &$log) {
  1311→            if (isset($log['context']) && is_string($log['context'])) {
  1312→                $log['context'] = json_decode($log['context'], true);
  1313→            }
  1314→        }
  1315→
  1316→        return [
  1317→            'logs' => $logs,
  1318→            'count' => count($logs)
  1319→        ];
  1320→    }
  1321→
  1322→    // =========================================================================
  1323→    // ALERTS
  1324→    // =========================================================================
  1325→
  1326→    /**
  1327→     * Get alerts with contact info
  1328→     */
  1329→    public function getAlertsWithContact(
  1330→        ?string $status = null,
  1331→        int $limit = 50
  1332→    ): array {
  1333→        if ($this->useMongo) {
  1334→            $filter = $status ? ['status' => $status] : [];
  1335→            $repo = new MongoRepository();
  1336→            return $repo->getAlertsWithContact($filter, ['limit' => $limit]);
  1337→        }
  1338→
  1339→        $whereClause = $status ? "a.status = ?" : "1=1";
  1340→        $params = $status ? [$status, $limit] : [$limit];
  1341→
  1342→        return $this->db->fetchAll(
  1343→            "SELECT a.*, c.name as contact_name, c.email as contact_email
  1344→             FROM alerts a
  1345→             LEFT JOIN contacts c ON a.contact_id = c.id
  1346→             WHERE {$whereClause}
  1347→             ORDER BY a.created_at DESC
  1348→             LIMIT ?",
  1349→            $params
  1350→        );
  1351→    }
  1352→
  1353→    // =========================================================================
  1354→    // BUSINESS INTELLIGENCE
  1355→    // =========================================================================
  1356→
  1357→    /**
  1358→     * Get churn risk indicators with contacts
  1359→     */
  1360→    public function getChurnRisk(int $limit = 20): array
  1361→    {
  1362→        if ($this->useMongo) {
  1363→            $repo = new MongoRepository();
  1364→            return $repo->getChurnRiskWithContacts(['limit' => $limit]);
  1365→        }
  1366→
  1367→        return $this->db->fetchAll(
  1368→            "SELECT cri.*, c.name, c.email, c.company
  1369→             FROM churn_risk_indicators cri
  1370→             JOIN contacts c ON cri.contact_id = c.id
  1371→             ORDER BY cri.risk_score DESC
  1372→             LIMIT ?",
  1373→            [$limit]
  1374→        );
  1375→    }
  1376→
  1377→    /**
  1378→     * Get business opportunities with contacts
  1379→     */
  1380→    public function getBusinessOpportunities(int $limit = 20): array
  1381→    {
  1382→        if ($this->useMongo) {
  1383→            $repo = new MongoRepository();
  1384→            return $repo->getBusinessOpportunities([], ['limit' => $limit]);
  1385→        }
  1386→
  1387→        return $this->db->fetchAll(
  1388→            "SELECT bo.*, c.name, c.email, c.company
  1389→             FROM business_opportunities bo
  1390→             JOIN contacts c ON bo.contact_id = c.id
  1391→             ORDER BY bo.created_at DESC
  1392→             LIMIT ?",
  1393→            [$limit]
  1394→        );
  1395→    }
  1396→
  1397→    /**
  1398→     * Get leverage/bargaining chips
  1399→     */
  1400→    public function getLeverage(int $contactId, int $limit = 10): array
  1401→    {
  1402→        if ($this->useMongo) {
  1403→            $repo = new MongoRepository();
  1404→            return $repo->getLeverageWithContacts(
  1405→                ['contact_id' => $contactId],
  1406→                ['limit' => $limit]
  1407→            );
  1408→        }
  1409→
  1410→        return $this->db->fetchAll(
  1411→            "SELECT lb.*, c.name as contact_name
  1412→             FROM leverage_bargaining lb
  1413→             LEFT JOIN contacts c ON lb.contact_id = c.id
  1414→             WHERE lb.contact_id = ?
  1415→             ORDER BY lb.strength DESC
  1416→             LIMIT ?",
  1417→            [$contactId, $limit]
  1418→        );
  1419→    }
  1420→
  1421→    // =========================================================================
  1422→    // STATISTICS
  1423→    // =========================================================================
  1424→
  1425→    /**
  1426→     * Get contact email statistics
  1427→     */
  1428→    public function getContactStats(int $contactId): array
  1429→    {
  1430→        if ($this->useMongo) {
  1431→            $repo = new MongoRepository();
  1432→            return $repo->getContactEmailStats($contactId);
  1433→        }
  1434→
  1435→        return $this->db->fetchOne(
  1436→            "SELECT
  1437→                COUNT(*) as email_count,
  1438→                SUM(CASE WHEN direction = 'sent' THEN 1 ELSE 0 END) as sent_count,
  1439→                SUM(CASE WHEN direction = 'received' THEN 1 ELSE 0 END) as received_count,
  1440→                MIN(date) as first_email,
  1441→                MAX(date) as last_email
  1442→             FROM emails
  1443→             WHERE contact_id = ?",
  1444→            [$contactId]
  1445→        ) ?? ['email_count' => 0, 'sent_count' => 0, 'received_count' => 0];
  1446→    }
  1447→
  1448→    // =========================================================================
  1449→    // RAW ACCESS (for custom queries)
  1450→    // =========================================================================
  1451→
  1452→    /**
  1453→     * Get the underlying Database instance (for SQL)
  1454→     */
  1455→    public function getDatabase(): Database
  1456→    {
  1457→        return $this->db;
  1458→    }
  1459→
  1460→    /**
  1461→     * Get the MongoRepository instance (for custom MongoDB queries)
  1462→     */
  1463→    public function getMongoRepository(): ?MongoRepository
  1464→    {
  1465→        return $this->mongoRepo;
  1466→    }
  1467→}
  1468→

Prompt:
Explore /var/www/mssql.danmarcrm.com/dev1/extension2v1/ with "very thorough" thoroughness.

I need to understand:

1. **Contact name resolution for threads**: The user says "not all email threads should be linked to Etienne Turner". Look at QueryAdapter.php methods `getThreadsMongo()` and `getThreadsSQL()` to see how thread contact names are resolved. This was supposedly fixed to find the most frequent inbound sender, but may still have issues.

2. **How threads get imported/synced**: Look at api.php for any "sync" or "import" or "save_thread" or "process_email" actions. How does data get from Gmail into the system? Look at the Chrome extension integration points.

3. **The Chrome extension**: Look at the api.php endpoints that the Chrome extension calls. Search for endpoints that create/save email threads.

4. **Thread origin tracking**: Is there a field that tracks WHERE a thread came from (manual sync vs auto-import)?

5. **Email sync by domain**: Is there any existing functionality to sync by domain or email address? Search for "domain" or "sync" in the codebase.

6. **Saved responses (AI)**: Look at the `ai_query_responses` or similar collection - what fields does it have? How are AI responses stored and retrieved?


260:        case 'status':
294:        case 'contacts':
328:        case 'contact':
355:        case 'threads':
389:        case 'thread':
468:        case 'thread_for_ai':
514:        case 'thread_participants':
593:        case 'logs':
613:        case 'learned_behaviors':
648:        case 'emails':
694:        case 'email':
739:        case 'quotes':
797:        case 'filters':
814:        case 'add_filter':
840:        case 'remove_filter':
864:        case 'sync':
911:        case 'analyze':
971:        case 'analyze_thread':
1010:        case 'analysis_status':
1079:        case 'analyze_contact':
1114:        case 'contact_profile':
1169:        case 'build_profile':
1189:        case 'semantic_search':
1221:        case 'find_patterns':
1246:        case 'index_vectors':
1289:        case 'vector_status':
1301:        case 'scan_patterns':
1320:        case 'detect_pattern':
1340:        case 'pattern_stats':
1355:        case 'similar_contacts':
1377:        case 'pricing_behavior':
1393:        case 'gmail_auth_url':
1412:        case 'gmail_accounts':
1428:        case 'gmail_account_add':
1466:        case 'gmail_account_remove':
1492:        case 'gmail_account_primary':
1514:        case 'gmail_account_toggle':
1539:        case 'update_quote':
1566:        case 'update_contact':
1600:        case 'alerts':
1629:        case 'alert_stats':
1639:        case 'scan_alerts':
1660:        case 'alert_read':
1677:        case 'alert_dismiss':
1694:        case 'analyze_batch':
1737:        case 'resolve_message_thread':
1843:        case 'sync_thread':
2107:        case 'save_gmail_thread':
2210:        case 'ask_ai':
2303:        case 'ai_chat':
2655:        case 'generate_reply':
2906:        case 'suggest_replies':
3072:        case 'client_insights':
3114:        case 'global_insights':
3149:        case 'insights_meta':
3167:        case 'ask_contact_ai':
3551:        case 'db_status':
3560:        case 'thread_id_cache':
3565:                case 'stats':
3572:                case 'lookup':
3585:                case 'list':
3592:                case 'clear_expired':
3608:        case 'quote_assistant':
3632:        case 'quote_stats':
3649:        case 'suggest_quote':
3671:        case 'extract_quotes':
3694:        case 'extraction_stats':
3709:        case 'list_quotes':
3754:        case 'projects':
3788:        case 'add_project':
3840:        case 'update_project':
3882:        case 'analyze_contact_full':
3927:        case 'analyze_contact_threads':
4030:        case 'stop_analysis':
4046:        case 'analysis_status':
4062:        case 'generate_email':
4389:        case 'count_filter_emails':
4439:        case 'enrich_contact':
4468:        case 'enrich_company':
4483:        case 'enrich_all_contacts':
4493:        case 'enrich_all_companies':
4503:        case 'suggest_enrichment_sources':
4527:        case 'approve_enrichment':
4555:        case 'enrichment_status':
4575:        case 'enrichment_queue':
4586:        case 'process_enrichment_queue':
4599:        case 'enrichment_logs':
4625:        case 'contact_enrichment':
4642:        case 'company_enrichment':
4659:        case 'apollo_status':
4670:        case 'puppeteer_status':
4681:        case 'enrichment_sources':
4693:        case 'save_important_context':
4716:        case 'get_important_context':
4729:        case 'delete_important_context':
4746:        case 'save_ai_response':
4800:        case 'log_ai_debug':
4839:        case 'get_ai_response':
4871:        case 'list_ai_responses':
4914:        case 'startProductSearch':
4948:        case 'getProductSearchStatus':
4965:        case 'getProductSearchResults':
4982:        case 'executeProductSearch':
5001:        case 'getProductSearchHistory':
5015:        case 'getProductSearchSites':
5030:        case 'saveProducts':
5084:        case 'business_ideas_projects':
5098:        case 'business_ideas_project':
5114:        case 'create_business_ideas_project':
5131:        case 'start_business_ideas_project':
5143:        case 'pause_business_ideas_project':
5155:        case 'resume_business_ideas_project':
5170:        case 'business_ideas_queue_status':
5178:        case 'process_business_ideas_queue':
5187:        case 'business_ideas_tasks':
5203:        case 'business_ideas_findings':
5218:        case 'business_ideas_scenarios':
5232:        case 'generate_business_ideas_report':
5244:        case 'generate_business_scenarios':
5261:        case 'business_context':
5268:        case 'get_owner_status':
5275:        case 'save_owner_status':
5294:        case 'web_search':
5320:        case 'web_search_urls':
5337:        case 'web_search_recent':
5356:        case 'web_search_status':
5364:        case 'extension_scrape_submit':
5412:        case 'extension_get_tasks':
5437:        case 'extension_queue_scrape':
5473:        case 'extension_complete_task':
5493:        case 'ai_chat_sessions':
5522:        case 'ai_chat_session':
5546:        case 'ai_chat_session_delete':
5559:        case 'ai_contact_memories':
5574:        case 'ai_contact_memory_add':
5601:        case 'ai_contact_memory_delete':

Prompt:
Explore the extension2v1 project at /var/www/mssql.danmarcrm.com/dev1/extension2v1/ with "very thorough" thoroughness.

I need to understand:

1. **Dashboard main page (index.php + app.js)**: How contacts and threads are loaded, displayed, sorted. Look at the HTML structure for contacts list and threads list. What columns/info are shown? Is there a "last activity" date? Are there any delete buttons currently? Any existing filter UI?

2. **API endpoints (api.php)**: What actions exist for contacts, threads, and AI responses? Look for any existing delete endpoints. Look for sync-related endpoints.

3. **QueryAdapter.php**: What methods exist for contacts, threads? Is there a method to delete contacts/threads/AI responses? How is "last activity" tracked?

4. **MongoDB collections**: What collections are used? What fields do they have? Look at MongoDatabase.php for available methods.

5. **product_search.php**: Does this file exist? What does it do?

6. **Thread sync mechanism**: How do email threads get into the system? Is it via Chrome extension? Where is the sync logic?

7. **The thread at thread_id=19bd4f343ca15018**: Can we find how threads get created/imported?

Focus on extension2v1 directory.


5631 /var/www/mssql.danmarcrm.com/dev1/extension2v1/public/api.php

Prompt:
Explore the extension2v1 project at /var/www/mssql.danmarcrm.com/dev1/extension2v1/ with "very thorough" thoroughness.

I need to understand:

1. **Dashboard main page (index.php + app.js)**: How contacts and threads are loaded, displayed, sorted. Look at the HTML structure for contacts list and threads list. What columns/info are shown? Is there a "last activity" date? Are there any delete buttons currently? Any existing filter UI?

2. **API endpoints (api.php)**: What actions exist for contacts, threads, and AI responses? Look for any existing delete endpoints. Look for sync-related endpoints.

3. **QueryAdapter.php**: What methods exist for contacts, threads? Is there a method to delete contacts/threads/AI responses? How is "last activity" tracked?

4. **MongoDB collections**: What collections are used? What fields do they have? Look at MongoDatabase.php for available methods.

5. **product_search.php**: Does this file exist? What does it do?

6. **Thread sync mechanism**: How do email threads get into the system? Is it via Chrome extension? Where is the sync logic?

7. **The thread at thread_id=19bd4f343ca15018**: Can we find how threads get created/imported?

Focus on extension2v1 directory.


Prompt:
Explore /var/www/mssql.danmarcrm.com/dev1/extension2v1/ with "very thorough" thoroughness.

I need to understand:

1. **Contact name resolution for threads**: The user says "not all email threads should be linked to Etienne Turner". Look at QueryAdapter.php methods `getThreadsMongo()` and `getThreadsSQL()` to see how thread contact names are resolved. This was supposedly fixed to find the most frequent inbound sender, but may still have issues.

2. **How threads get imported/synced**: Look at api.php for any "sync" or "import" or "save_thread" or "process_email" actions. How does data get from Gmail into the system? Look at the Chrome extension integration points.

3. **The Chrome extension**: Look at the api.php endpoints that the Chrome extension calls. Search for endpoints that create/save email threads.

4. **Thread origin tracking**: Is there a field that tracks WHERE a thread came from (manual sync vs auto-import)?

5. **Email sync by domain**: Is there any existing functionality to sync by domain or email address? Search for "domain" or "sync" in the codebase.

6. **Saved responses (AI)**: Look at the `ai_query_responses` or similar collection - what fields does it have? How are AI responses stored and retrieved?


Bash

grep -n "delete\|remove\|destroy" /var/www/mssql.danmarcrm.com/dev1/extension2v1/public/api.php | head -30

Search for delete/remove operations in API

Prompt:
Explore the extension2v1 project at /var/www/mssql.danmarcrm.com/dev1/extension2v1/ with "very thorough" thoroughness.

I need to understand:

1. **Dashboard main page (index.php + app.js)**: How contacts and threads are loaded, displayed, sorted. Look at the HTML structure for contacts list and threads list. What columns/info are shown? Is there a "last activity" date? Are there any delete buttons currently? Any existing filter UI?

2. **API endpoints (api.php)**: What actions exist for contacts, threads, and AI responses? Look for any existing delete endpoints. Look for sync-related endpoints.

3. **QueryAdapter.php**: What methods exist for contacts, threads? Is there a method to delete contacts/threads/AI responses? How is "last activity" tracked?

4. **MongoDB collections**: What collections are used? What fields do they have? Look at MongoDatabase.php for available methods.

5. **product_search.php**: Does this file exist? What does it do?

6. **Thread sync mechanism**: How do email threads get into the system? Is it via Chrome extension? Where is the sync logic?

7. **The thread at thread_id=19bd4f343ca15018**: Can we find how threads get created/imported?

Focus on extension2v1 directory.


21: *   POST ?action=remove_filter    - Remove email filter
840:        case 'remove_filter':
853:            $removed = $sync->removeFilter($email);
856:                'success' => $removed > 0,
857:                'message' => $removed > 0 ? "Filter removed: {$email}" : "Filter not found"
1466:        case 'gmail_account_remove':
1479:                $accountManager->delete($accountId, true);
1482:                    'message' => 'Account removed'
3440:                // Helper to sanitize text for AI (remove invalid UTF-8, null bytes, etc.)
4729:        case 'delete_important_context':
4738:            $success = $ctx->delete($id);
5546:        case 'ai_chat_session_delete':
5552:            $deleted = $ctxMongo->delete('ai_chat_sessions', ['session_id' => $sessionId]);
5553:            jsonResponse(['success' => true, 'deleted' => $deleted]);
5601:        case 'ai_contact_memory_delete':

Prompt:
Explore the extension2v1 project at /var/www/mssql.danmarcrm.com/dev1/extension2v1/ with "very thorough" thoroughness.

I need to understand:

1. **Dashboard main page (index.php + app.js)**: How contacts and threads are loaded, displayed, sorted. Look at the HTML structure for contacts list and threads list. What columns/info are shown? Is there a "last activity" date? Are there any delete buttons currently? Any existing filter UI?

2. **API endpoints (api.php)**: What actions exist for contacts, threads, and AI responses? Look for any existing delete endpoints. Look for sync-related endpoints.

3. **QueryAdapter.php**: What methods exist for contacts, threads? Is there a method to delete contacts/threads/AI responses? How is "last activity" tracked?

4. **MongoDB collections**: What collections are used? What fields do they have? Look at MongoDatabase.php for available methods.

5. **product_search.php**: Does this file exist? What does it do?

6. **Thread sync mechanism**: How do email threads get into the system? Is it via Chrome extension? Where is the sync logic?

7. **The thread at thread_id=19bd4f343ca15018**: Can we find how threads get created/imported?

Focus on extension2v1 directory.