     1→/**
     2→ * Perplexity AI Service
     3→ * Web search for company and person research
     4→ */
     5→
     6→const { supabase } = require('../config/database');
     7→const { fetchWithTimeout, isTimeoutError } = require('../utils/fetchWithTimeout');
     8→
     9→const PERPLEXITY_API_URL = 'https://api.perplexity.ai/chat/completions';
    10→const MODEL = 'sonar'; // Default, cheapest model
    11→const CACHE_DAYS = 30;
    12→
    13→/**
    14→ * Make a Perplexity API request
    15→ */
    16→async function perplexityRequest(prompt) {
    17→  const apiKey = process.env.PERPLEXITY_API_KEY;
    18→
    19→  if (!apiKey) {
    20→    console.warn('[Perplexity] API key not configured');
    21→    return { success: false, error: 'Perplexity API key not configured' };
    22→  }
    23→
    24→  try {
    25→    const response = await fetchWithTimeout(PERPLEXITY_API_URL, {
    26→      method: 'POST',
    27→      headers: {
    28→        'Authorization': `Bearer ${apiKey}`,
    29→        'Content-Type': 'application/json',
    30→      },
    31→      body: JSON.stringify({
    32→        model: MODEL,
    33→        messages: [{ role: 'user', content: prompt }],
    34→      }),
    35→    }, 45000); // 45 second timeout - research queries take time
    36→
    37→    if (!response.ok) {
    38→      const error = await response.text();
    39→      console.error('[Perplexity] API error:', response.status, error);
    40→      return { success: false, error: `API error: ${response.status}` };
    41→    }
    42→
    43→    const data = await response.json();
    44→    const content = data.choices?.[0]?.message?.content;
    45→
    46→    return { success: true, content };
    47→  } catch (error) {
    48→    if (isTimeoutError(error)) {
    49→      console.error('[Perplexity] Request timed out after 45 seconds');
    50→      return { success: false, error: 'Request timed out' };
    51→    }
    52→    console.error('[Perplexity] Request error:', error);
    53→    return { success: false, error: error.message };
    54→  }
    55→}
    56→
    57→/**
    58→ * Normalize company name for caching
    59→ */
    60→function normalizeCompanyName(name) {
    61→  return name
    62→    .toLowerCase()
    63→    .replace(/\s+(pty|ltd|limited|inc|llc|corp|corporation)\s*/gi, '')
    64→    .replace(/[^a-z0-9]/g, '')
    65→    .trim();
    66→}
    67→
    68→/**
    69→ * Check if cached result is still valid
    70→ */
    71→function isCacheValid(searchedAt) {
    72→  if (!searchedAt) return false;
    73→  const cacheExpiry = new Date(searchedAt);
    74→  cacheExpiry.setDate(cacheExpiry.getDate() + CACHE_DAYS);
    75→  return new Date() < cacheExpiry;
    76→}
    77→
    78→/**
    79→ * Search for company information
    80→ * @param {string} companyName - Company name to research
    81→ * @returns {Promise<object>} Company intel
    82→ */
    83→async function searchCompany(companyName) {
    84→  if (!companyName || companyName.trim().length < 2) {
    85→    return { success: false, error: 'Company name required' };
    86→  }
    87→
    88→  const normalized = normalizeCompanyName(companyName);
    89→
    90→  // Check cache first
    91→  if (supabase) {
    92→    const { data: cached } = await supabase
    93→      .from('company_intel')
    94→      .select('*')
    95→      .eq('company_name_normalized', normalized)
    96→      .single();
    97→
    98→    if (cached && isCacheValid(cached.searched_at)) {
    99→      console.log('[Perplexity] Using cached company intel for:', companyName);
   100→      return { success: true, data: cached, cached: true };
   101→    }
   102→  }
   103→
   104→  // Search with Perplexity - always include Australia context
   105→  const prompt = `Research this company: "${companyName}" construction Australia
   106→
   107→Search specifically for Australian companies. Find their website and business details. Provide information in this exact JSON format:
   108→{
   109→  "summary": "2-3 sentence overview of what the company does",
   110→  "size": "small/medium/large/enterprise (estimate)",
   111→  "industry": "Primary industry/sector",
   112→  "location": "City/State, Australia",
   113→  "website_url": "Company website URL if found (e.g., https://www.example.com.au), or null",
   114→  "abn": "Australian Business Number if found (format: XX XXX XXX XXX), or null",
   115→  "linkedin_url": "LinkedIn company page URL if found, or null",
   116→  "facebook_url": "Facebook business page URL if found, or null",
   117→  "recent_projects": ["Recent project or news 1", "Recent project or news 2"],
   118→  "hiring_signals": ["Any hiring activity or growth signals"],
   119→  "ai_sales_angle": "Best approach for pitching RateRight (a marketplace where contractors post jobs and workers apply direct, 9.9% fee vs 30-40% agency margins, no middleman). Focus on their likely pain points."
   120→}
   121→
   122→IMPORTANT: Try hard to find the actual company website URL. Search for "[company name] construction australia website" if needed.
   123→If you can't find specific information, make reasonable inferences based on the company name and industry.
   124→Return ONLY valid JSON, no other text.`;
   125→
   126→  const result = await perplexityRequest(prompt);
   127→
   128→  if (!result.success) {
   129→    return result;
   130→  }
   131→
   132→  // Parse the response
   133→  let intel;
   134→  try {
   135→    // Clean up response - remove markdown code blocks if present
   136→    let content = result.content.trim();
   137→    if (content.startsWith('```json')) {
   138→      content = content.slice(7);
   139→    }
   140→    if (content.startsWith('```')) {
   141→      content = content.slice(3);
   142→    }
   143→    if (content.endsWith('```')) {
   144→      content = content.slice(0, -3);
   145→    }
   146→    intel = JSON.parse(content.trim());
   147→  } catch (e) {
   148→    console.error('[Perplexity] Failed to parse company response:', e);
   149→    return { success: false, error: 'Failed to parse response' };
   150→  }
   151→
   152→  // Store in cache
   153→  if (supabase) {
   154→    const record = {
   155→      company_name: companyName,
   156→      company_name_normalized: normalized,
   157→      summary: intel.summary,
   158→      size: intel.size,
   159→      industry: intel.industry,
   160→      location: intel.location,
   161→      website_url: intel.website_url || null,
   162→      abn: intel.abn || null,
   163→      linkedin_url: intel.linkedin_url || null,
   164→      facebook_url: intel.facebook_url || null,
   165→      recent_projects: intel.recent_projects,
   166→      hiring_signals: intel.hiring_signals,
   167→      ai_sales_angle: intel.ai_sales_angle,
   168→      searched_at: new Date().toISOString(),
   169→    };
   170→
   171→    // Upsert based on normalized name
   172→    const { data: existing } = await supabase
   173→      .from('company_intel')
   174→      .select('id')
   175→      .eq('company_name_normalized', normalized)
   176→      .single();
   177→
   178→    if (existing) {
   179→      await supabase
   180→        .from('company_intel')
   181→        .update(record)
   182→        .eq('id', existing.id);
   183→    } else {
   184→      await supabase
   185→        .from('company_intel')
   186→        .insert(record);
   187→    }
   188→
   189→    console.log('[Perplexity] Cached company intel for:', companyName);
   190→  }
   191→
   192→  return { success: true, data: intel, cached: false };
   193→}
   194→
   195→/**
   196→ * Search for person information
   197→ * @param {string} leadId - Lead ID to research
   198→ * @returns {Promise<object>} Person intel
   199→ */
   200→async function searchPerson(leadId) {
   201→  if (!leadId) {
   202→    return { success: false, error: 'Lead ID required' };
   203→  }
   204→
   205→  // Get lead data
   206→  if (!supabase) {
   207→    return { success: false, error: 'Database not configured' };
   208→  }
   209→
   210→  const { data: lead, error: leadError } = await supabase
   211→    .from('leads')
   212→    .select('*')
   213→    .eq('id', leadId)
   214→    .single();
   215→
   216→  if (leadError || !lead) {
   217→    return { success: false, error: 'Lead not found' };
   218→  }
   219→
   220→  const fullName = `${lead.first_name || ''} ${lead.last_name || ''}`.trim();
   221→  if (!fullName || fullName.length < 2) {
   222→    return { success: false, error: 'Lead name required for person research' };
   223→  }
   224→
   225→  // Check cache first
   226→  const { data: cached } = await supabase
   227→    .from('lead_intel')
   228→    .select('*')
   229→    .eq('lead_id', leadId)
   230→    .single();
   231→
   232→  if (cached && isCacheValid(cached.searched_at)) {
   233→    console.log('[Perplexity] Using cached person intel for:', fullName);
   234→    return { success: true, data: cached, cached: true };
   235→  }
   236→
   237→  // Build search context - always include Australia and LinkedIn
   238→  const companyContext = lead.company ? ` ${lead.company}` : '';
   239→  const researchHint = lead.metadata?.research_hint || '';
   240→
   241→  // Search with Perplexity - include Australia LinkedIn for accurate results
   242→  // Now includes personal profile research
   243→  const prompt = `Research this person: "${fullName}"${companyContext} Australia LinkedIn Facebook${researchHint ? `\n\nAdditional context: ${researchHint}` : ''}
   244→
   245→Search specifically for this person in Australia. Find both PROFESSIONAL and PERSONAL information.
   246→
   247→Provide information in this exact JSON format:
   248→{
   249→  "linkedin_url": "LinkedIn profile URL if found, or null",
   250→  "job_title": "Current job title or role",
   251→  "years_at_company": null or number,
   252→  "previous_companies": ["Previous company 1", "Previous company 2"],
   253→  "summary": "2-3 sentence professional summary",
   254→
   255→  "personal_profile": {
   256→    "family_status": "Married with 2 kids" or "Single" or null if unknown,
   257→    "hobbies": ["fishing", "golf", "rugby league", "camping"],
   258→    "interests": ["property investment", "4WDs", "home renovation"],
   259→    "location_suburb": "Suburb, City" if found,
   260→    "personality_notes": "Seems like a straight-shooter / family man / ambitious etc based on social presence"
   261→  },
   262→
   263→  "social_profiles": {
   264→    "facebook": "Facebook profile URL if found",
   265→    "instagram": "Instagram profile URL if found",
   266→    "twitter": "Twitter/X profile URL if found"
   267→  },
   268→
   269→  "conversation_starters": [
   270→    "Ask about [hobby] - they seem passionate about it",
   271→    "Mention [local team/event] if they're a fan",
   272→    "They [posted/mentioned] something about [topic] recently"
   273→  ],
   274→
   275→  "ai_talking_points": "Personalized talking points for pitching RateRight - a marketplace where they post jobs and workers apply direct (9.9% fee vs 30-40% agency margins). Focus on their role and likely pain points with finding reliable workers."
   276→}
   277→
   278→TIPS FOR FINDING PERSONAL INFO:
   279→- Check LinkedIn for interests, volunteer work, posts about hobbies
   280→- Facebook often shows family, hobbies, sports teams they follow
   281→- Look for patterns: tradies often like fishing, camping, 4WDs, footy
   282→- Construction industry people often have property investment interests
   283→
   284→Focus on Australian results. Make reasonable inferences based on their role in construction/trades.
   285→Return ONLY valid JSON, no other text.`;
   286→
   287→  const result = await perplexityRequest(prompt);
   288→
   289→  if (!result.success) {
   290→    return result;
   291→  }
   292→
   293→  // Parse the response
   294→  let intel;
   295→  try {
   296→    let content = result.content.trim();
   297→    if (content.startsWith('```json')) {
   298→      content = content.slice(7);
   299→    }
   300→    if (content.startsWith('```')) {
   301→      content = content.slice(3);
   302→    }
   303→    if (content.endsWith('```')) {
   304→      content = content.slice(0, -3);
   305→    }
   306→    intel = JSON.parse(content.trim());
   307→  } catch (e) {
   308→    console.error('[Perplexity] Failed to parse person response:', e);
   309→    return { success: false, error: 'Failed to parse response' };
   310→  }
   311→
   312→  // Store in cache - including new personal fields
   313→  const record = {
   314→    lead_id: leadId,
   315→    linkedin_url: intel.linkedin_url,
   316→    job_title: intel.job_title,
   317→    years_at_company: intel.years_at_company,
   318→    previous_companies: intel.previous_companies,
   319→    summary: intel.summary,
   320→    ai_talking_points: intel.ai_talking_points,
   321→    // New personal fields
   322→    family_status: intel.personal_profile?.family_status || null,
   323→    hobbies: intel.personal_profile?.hobbies || null,
   324→    interests: intel.personal_profile?.interests || null,
   325→    location_suburb: intel.personal_profile?.location_suburb || null,
   326→    personality_notes: intel.personal_profile?.personality_notes || null,
   327→    conversation_starters: intel.conversation_starters || null,
   328→    social_profiles: intel.social_profiles || null,
   329→    searched_at: new Date().toISOString(),
   330→  };
   331→
   332→  // Upsert based on lead_id
   333→  if (cached) {
   334→    await supabase
   335→      .from('lead_intel')
   336→      .update(record)
   337→      .eq('lead_id', leadId);
   338→  } else {
   339→    await supabase
   340→      .from('lead_intel')
   341→      .insert(record);
   342→  }
   343→
   344→  console.log('[Perplexity] Cached person intel for:', fullName);
   345→
   346→  return { success: true, data: intel, cached: false };
   347→}
   348→
   349→/**
   350→ * Get cached company intel without searching
   351→ */
   352→async function getCachedCompanyIntel(companyName) {
   353→  if (!supabase || !companyName) return null;
   354→
   355→  const normalized = normalizeCompanyName(companyName);
   356→  const { data } = await supabase
   357→    .from('company_intel')
   358→    .select('*')
   359→    .eq('company_name_normalized', normalized)
   360→    .single();
   361→
   362→  return data;
   363→}
   364→
   365→/**
   366→ * Get cached lead intel without searching
   367→ */
   368→async function getCachedLeadIntel(leadId) {
   369→  if (!supabase || !leadId) return null;
   370→
   371→  const { data } = await supabase
   372→    .from('lead_intel')
   373→    .select('*')
   374→    .eq('lead_id', leadId)
   375→    .single();
   376→
   377→  return data;
   378→}
   379→
   380→/**
   381→ * Auto-research from call transcript
   382→ * Extracts intel from transcript and triggers background research
   383→ * @param {string} leadId - Lead ID
   384→ * @param {string} transcript - Call transcript
   385→ * @returns {Promise<object>} Research results
   386→ */
   387→async function autoResearchFromTranscript(leadId, transcript) {
   388→  if (!transcript || transcript.length < 50) {
   389→    console.log('[AutoResearch] Transcript too short, skipping');
   390→    return { success: false, error: 'Transcript too short' };
   391→  }
   392→
   393→  console.log(`[AutoResearch] Starting for lead ${leadId}`);
   394→
   395→  try {
   396→    // Import ai service dynamically to avoid circular dependency
   397→    const aiService = require('./ai');
   398→
   399→    // Extract intel from transcript using GPT
   400→    const extractResult = await aiService.extractTranscriptIntel(transcript);
   401→
   402→    if (!extractResult.success || !extractResult.intel) {
   403→      console.log('[AutoResearch] No intel extracted from transcript');
   404→      return { success: false, error: 'No intel extracted' };
   405→    }
   406→
   407→    const intel = extractResult.intel;
   408→    const researchResults = {
   409→      companiesResearched: [],
   410→      intelStored: [],
   411→    };
   412→
   413→    // Research company if mentioned and not cached
   414→    if (intel.company_to_research) {
   415→      const companyName = intel.company_to_research;
   416→      const normalized = normalizeCompanyName(companyName);
   417→
   418→      // Check if already cached
   419→      const cached = await getCachedCompanyIntel(companyName);
   420→
   421→      if (cached && isCacheValid(cached.searched_at)) {
   422→        console.log(`[AutoResearch] Company "${companyName}" already cached`);
   423→        researchResults.companiesResearched.push({ name: companyName, cached: true });
   424→      } else {
   425→        console.log(`[AutoResearch] Researching company: ${companyName}`);
   426→        const companyResult = await searchCompany(companyName);
   427→        researchResults.companiesResearched.push({
   428→          name: companyName,
   429→          success: companyResult.success,
   430→          cached: false
   431→        });
   432→      }
   433→    }
   434→
   435→    // Also research any other companies mentioned
   436→    if (intel.companies_mentioned?.length > 0) {
   437→      for (const company of intel.companies_mentioned.slice(0, 2)) { // Limit to 2
   438→        if (company.name && company.name !== intel.company_to_research) {
   439→          const cached = await getCachedCompanyIntel(company.name);
   440→          if (!cached || !isCacheValid(cached.searched_at)) {
   441→            console.log(`[AutoResearch] Also researching mentioned company: ${company.name}`);
   442→            const result = await searchCompany(company.name);
   443→            researchResults.companiesResearched.push({
   444→              name: company.name,
   445→              success: result.success,
   446→              cached: false,
   447→            });
   448→          }
   449→        }
   450→      }
   451→    }
   452→
   453→    // Store intel nuggets as lead notes
   454→    if (supabase && intel.intel_nuggets?.length > 0) {
   455→      const nuggetNotes = intel.intel_nuggets
   456→        .map(n => `[${n.type.toUpperCase()}] ${n.detail}`)
   457→        .join('\n');
   458→
   459→      if (nuggetNotes) {
   460→        await supabase
   461→          .from('lead_notes')
   462→          .insert({
   463→            lead_id: leadId,
   464→            content: `[Auto-Extracted Intel]\n${nuggetNotes}`,
   465→            created_by: 'auto-research',
   466→          });
   467→
   468→        researchResults.intelStored = intel.intel_nuggets;
   469→        console.log(`[AutoResearch] Stored ${intel.intel_nuggets.length} intel nuggets`);
   470→      }
   471→    }
   472→
   473→    // Store projects mentioned as notes
   474→    if (supabase && intel.projects_mentioned?.length > 0) {
   475→      const projectNotes = intel.projects_mentioned
   476→        .map(p => `• ${p.name} (${p.type}): ${p.context}`)
   477→        .join('\n');
   478→
   479→      if (projectNotes) {
   480→        await supabase
   481→          .from('lead_notes')
   482→          .insert({
   483→            lead_id: leadId,
   484→            content: `[Projects Mentioned]\n${projectNotes}`,
   485→            created_by: 'auto-research',
   486→          });
   487→        console.log(`[AutoResearch] Stored ${intel.projects_mentioned.length} projects`);
   488→      }
   489→    }
   490→
   491→    // Store people mentioned as notes
   492→    if (supabase && intel.people_mentioned?.length > 0) {
   493→      const peopleNotes = intel.people_mentioned
   494→        .filter(p => p.name && p.name.length > 2)
   495→        .map(p => `• ${p.name}${p.role ? ` (${p.role})` : ''}${p.company ? ` at ${p.company}` : ''}`)
   496→        .join('\n');
   497→
   498→      if (peopleNotes) {
   499→        await supabase
   500→          .from('lead_notes')
   501→          .insert({
   502→            lead_id: leadId,
   503→            content: `[People Mentioned]\n${peopleNotes}`,
   504→            created_by: 'auto-research',
   505→          });
   506→        console.log(`[AutoResearch] Stored ${intel.people_mentioned.length} people mentioned`);
   507→      }
   508→    }
   509→
   510→    console.log(`[AutoResearch] Completed for lead ${leadId}`);
   511→    return { success: true, results: researchResults };
   512→
   513→  } catch (error) {
   514→    console.error('[AutoResearch] Error:', error);
   515→    return { success: false, error: error.message };
   516→  }
   517→}
   518→
   519→/**
   520→ * Search for decision maker at a company (for contractor extraction)
   521→ * Used when a worker mentions who they work for
   522→ * @param {string} companyName - Company name
   523→ * @param {string} projectName - Project/site name (optional)
   524→ * @returns {Promise<object>} Decision maker info
   525→ */
   526→async function searchDecisionMaker(companyName, projectName = null) {
   527→  if (!companyName || companyName.trim().length < 2) {
   528→    return { success: false, error: 'Company name required' };
   529→  }
   530→
   531→  console.log(`[Perplexity] Searching for decision maker at ${companyName}${projectName ? ` on ${projectName}` : ''}`);
   532→
   533→  // Build search query
   534→  const projectContext = projectName ? ` "${projectName}"` : '';
   535→
   536→  const prompt = `Find the hiring decision maker at this Australian construction company: "${companyName}"${projectContext}
   537→
   538→Search for:
   539→1. "${companyName}" hiring manager Australia
   540→2. "${companyName}" operations manager Australia
   541→3. "${companyName}" who hires workers Australia
   542→4. "${companyName}" HR manager recruitment Australia
   543→
   544→Find a REAL person who makes hiring decisions for workers at this company. Provide in this exact JSON format:
   545→{
   546→  "found": true or false,
   547→  "confidence": 0.0-1.0 (how confident you found the right person),
   548→  "person": {
   549→    "first_name": "First name",
   550→    "last_name": "Last name",
   551→    "job_title": "Their role (Hiring Manager, Operations Manager, HR Manager, Director, etc.)",
   552→    "linkedin_url": "LinkedIn profile URL if found, or null",
   553→    "email": "Email if publicly available, or null",
   554→    "phone": "Phone if publicly available, or null"
   555→  },
   556→  "company_info": {
   557→    "full_name": "Full company name",
   558→    "location": "City/State, Australia",
   559→    "size": "small/medium/large estimate"
   560→  },
   561→  "search_notes": "Brief notes about what you found or why you couldn't find someone"
   562→}
   563→
   564→IMPORTANT:
   565→- Only return found=true if you found a SPECIFIC real person's name
   566→- Don't make up names - if you can't find anyone, return found=false
   567→- Prioritize people who actually HIRE workers (hiring managers, operations managers, HR)
   568→- Site supervisors are less useful - we want decision makers
   569→- Focus on Australian results
   570→
   571→Return ONLY valid JSON, no other text.`;
   572→
   573→  const result = await perplexityRequest(prompt);
   574→
   575→  if (!result.success) {
   576→    return result;
   577→  }
   578→
   579→  // Parse the response
   580→  let searchResult;
   581→  try {
   582→    let content = result.content.trim();
   583→    if (content.startsWith('```json')) {
   584→      content = content.slice(7);
   585→    }
   586→    if (content.startsWith('```')) {
   587→      content = content.slice(3);
   588→    }
   589→    if (content.endsWith('```')) {
   590→      content = content.slice(0, -3);
   591→    }
   592→    searchResult = JSON.parse(content.trim());
   593→  } catch (e) {
   594→    console.error('[Perplexity] Failed to parse decision maker response:', e);
   595→    return { success: false, error: 'Failed to parse response' };
   596→  }
   597→
   598→  if (!searchResult.found || !searchResult.person?.first_name) {
   599→    console.log(`[Perplexity] No decision maker found for ${companyName}`);
   600→    return {
   601→      success: true,
   602→      found: false,
   603→      notes: searchResult.search_notes || 'No decision maker found'
   604→    };
   605→  }
   606→
   607→  console.log(`[Perplexity] Found decision maker: ${searchResult.person.first_name} ${searchResult.person.last_name} at ${companyName}`);
   608→
   609→  return {
   610→    success: true,
   611→    found: true,
   612→    confidence: searchResult.confidence || 0.5,
   613→    person: searchResult.person,
   614→    company: searchResult.company_info,
   615→    notes: searchResult.search_notes,
   616→  };
   617→}
   618→
   619→/**
   620→ * Search for current news on a lead's interests/hobbies
   621→ * Used to make conversation starters timely and relevant
   622→ * @param {array} interests - Array of interests (e.g., ["Liverpool FC", "Mayo GAA", "F1"])
   623→ * @returns {Promise<object>} Current news on those interests
   624→ */
   625→async function searchInterestNews(interests) {
   626→  if (!interests || interests.length === 0) {
   627→    return { success: false, error: 'No interests provided' };
   628→  }
   629→
   630→  // Take top 4 interests to keep query focused
   631→  const topInterests = interests.slice(0, 4);
   632→
   633→  console.log('[Perplexity] Searching for news on interests:', topInterests);
   634→
   635→  const prompt = `Find the LATEST NEWS (last 7 days) for these topics. Focus on Australian/Irish/UK sports results and current events:
   636→
   637→Topics: ${topInterests.join(', ')}
   638→
   639→For EACH topic, find the most recent headline or result. Return in this exact JSON format:
   640→{
   641→  "news_items": [
   642→    {
   643→      "topic": "Liverpool FC",
   644→      "headline": "Liverpool beat Manchester United 3-0 at Anfield",
   645→      "detail": "Mo Salah scored twice as Liverpool moved top of the Premier League",
   646→      "date": "Yesterday" or "2 days ago" or "Jan 15",
   647→      "conversation_hook": "Did you see Liverpool smash United on the weekend? Salah was unreal!"
   648→    },
   649→    {
   650→      "topic": "Mayo GAA",
   651→      "headline": "Mayo to face Dublin in Allianz League opener",
   652→      "detail": "The first round clash is scheduled for Croke Park on January 28",
   653→      "date": "This week",
   654→      "conversation_hook": "Big game coming up - Mayo vs Dublin. Reckon this is the year?"
   655→    }
   656→  ],
   657→  "talking_points": [
   658→    "Great conversation starter based on the news",
   659→    "Another timely topic to mention"
   660→  ]
   661→}
   662→
   663→IMPORTANT:
   664→- Only include REAL, CURRENT news from the last 7 days
   665→- If you can't find recent news on a topic, skip it (don't make things up)
   666→- Focus on sports results, upcoming matches, or major news
   667→- Make conversation_hook casual and Aussie-friendly
   668→- Return ONLY valid JSON, no other text.`;
   669→
   670→  const result = await perplexityRequest(prompt);
   671→
   672→  if (!result.success) {
   673→    return result;
   674→  }
   675→
   676→  // Parse the response
   677→  let newsData;
   678→  try {
   679→    let content = result.content.trim();
   680→    if (content.startsWith('```json')) {
   681→      content = content.slice(7);
   682→    }
   683→    if (content.startsWith('```')) {
   684→      content = content.slice(3);
   685→    }
   686→    if (content.endsWith('```')) {
   687→      content = content.slice(0, -3);
   688→    }
   689→    newsData = JSON.parse(content.trim());
   690→  } catch (e) {
   691→    console.error('[Perplexity] Failed to parse interest news response:', e);
   692→    return { success: false, error: 'Failed to parse response' };
   693→  }
   694→
   695→  console.log(`[Perplexity] Found ${newsData.news_items?.length || 0} news items on interests`);
   696→
   697→  return {
   698→    success: true,
   699→    news_items: newsData.news_items || [],
   700→    talking_points: newsData.talking_points || [],
   701→  };
   702→}
   703→
   704→module.exports = {
   705→  searchCompany,
   706→  searchPerson,
   707→  searchDecisionMaker,
   708→  searchInterestNews,
   709→  getCachedCompanyIntel,
   710→  getCachedLeadIntel,
   711→  normalizeCompanyName,
   712→  autoResearchFromTranscript,
   713→};
   714→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Rep Performance Service
     3→ * Aggregates and analyzes rep performance data for weekly reports
     4→ */
     5→
     6→const { supabase, supabaseAdmin } = require('../config/database');
     7→const { callOpenAI } = require('./ai');
     8→
     9→/**
    10→ * Get the start of the current week (Monday) in AEST
    11→ */
    12→function getWeekStartAEST(date = new Date()) {
    13→  const AEST_OFFSET_MS = 10 * 60 * 60 * 1000;
    14→  const aestTime = new Date(date.getTime() + AEST_OFFSET_MS);
    15→  const aestDay = aestTime.getUTCDay();
    16→  const daysToMonday = aestDay === 0 ? 6 : aestDay - 1;
    17→  const aestWeekStart = new Date(Date.UTC(
    18→    aestTime.getUTCFullYear(),
    19→    aestTime.getUTCMonth(),
    20→    aestTime.getUTCDate() - daysToMonday,
    21→    0, 0, 0, 0
    22→  ));
    23→  return new Date(aestWeekStart.getTime() - AEST_OFFSET_MS);
    24→}
    25→
    26→/**
    27→ * Get all reps from user_xp AND active reps from communications
    28→ */
    29→async function getAllReps() {
    30→  const db = supabaseAdmin || supabase;
    31→  if (!db) return [];
    32→
    33→  // Get users from user_xp
    34→  const { data: xpUsers } = await db
    35→    .from('user_xp')
    36→    .select('user_id, display_name, level, current_streak, total_xp');
    37→
    38→  // Also find active reps from recent communications (last 30 days)
    39→  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
    40→  const { data: commsReps } = await db
    41→    .from('communications')
    42→    .select('handled_by, metadata')
    43→    .not('handled_by', 'is', null)
    44→    .gte('created_at', thirtyDaysAgo);
    45→
    46→  // Extract unique rep IDs from communications
    47→  const repIds = new Set();
    48→  (commsReps || []).forEach(c => {
    49→    if (c.handled_by) repIds.add(c.handled_by);
    50→    if (c.metadata?.handled_by) repIds.add(c.metadata.handled_by);
    51→  });
    52→
    53→  // Build combined rep list
    54→  const userMap = {};
    55→  (xpUsers || []).forEach(u => {
    56→    userMap[u.user_id] = u;
    57→  });
    58→
    59→  // Add any rep from communications not in user_xp
    60→  repIds.forEach(repId => {
    61→    if (!userMap[repId] && repId && repId !== 'system' && repId !== 'auto-transcribe') {
    62→      userMap[repId] = {
    63→        user_id: repId,
    64→        display_name: repId, // Use ID as name if no display_name
    65→        level: 1,
    66→        current_streak: 0,
    67→        total_xp: 0,
    68→      };
    69→    }
    70→  });
    71→
    72→  return Object.values(userMap);
    73→}
    74→
    75→/**
    76→ * Aggregate weekly stats for a single rep
    77→ */
    78→async function aggregateRepWeeklyStats(userId, weekStart, weekEnd) {
    79→  const db = supabaseAdmin || supabase;
    80→  if (!db) return null;
    81→
    82→  // Get communications for this rep this week
    83→  const { data: comms } = await db
    84→    .from('communications')
    85→    .select('id, type, direction, created_at, metadata, lead_id')
    86→    .or(`handled_by.eq.${userId},metadata->>handled_by.eq.${userId}`)
    87→    .gte('created_at', weekStart.toISOString())
    88→    .lt('created_at', weekEnd.toISOString());
    89→
    90→  // Get conversions for this rep this week
    91→  const { data: conversions } = await db
    92→    .from('leads')
    93→    .select('id, first_name, last_name, trade, updated_at')
    94→    .eq('status', 'converted')
    95→    .or(`metadata->>converted_by.eq.${userId},metadata->>handled_by.eq.${userId}`)
    96→    .gte('updated_at', weekStart.toISOString())
    97→    .lt('updated_at', weekEnd.toISOString());
    98→
    99→  // Get call quality scores for this rep
   100→  const { data: callScores } = await db
   101→    .from('call_quality_scores')
   102→    .select('overall_score, talk_ratio, led_to_conversion')
   103→    .eq('user_id', userId)
   104→    .gte('created_at', weekStart.toISOString())
   105→    .lt('created_at', weekEnd.toISOString());
   106→
   107→  // Process communications
   108→  let totalCalls = 0;
   109→  let totalSms = 0;
   110→  let inboundSms = 0;
   111→  let outboundSms = 0;
   112→  let totalCallDuration = 0;
   113→  let callsWithDuration = 0;
   114→  let objectionsCount = 0;
   115→  const hourlyConversions = {};
   116→  const dailyConversions = {};
   117→  const topCalls = [];
   118→
   119→  (comms || []).forEach(comm => {
   120→    const isCall = comm.type?.includes('call');
   121→
   122→    if (isCall) {
   123→      totalCalls++;
   124→      const duration = comm.metadata?.duration_seconds || 0;
   125→      if (duration > 0) {
   126→        totalCallDuration += duration;
   127→        callsWithDuration++;
   128→      }
   129→
   130→      // Track objections
   131→      const objections = comm.metadata?.objections || [];
   132→      objectionsCount += objections.length;
   133→
   134→      // Track top calls (connected with good duration)
   135→      if (comm.metadata?.outcome === 'converted' || (duration > 120 && comm.metadata?.outcome === 'connected')) {
   136→        topCalls.push({
   137→          id: comm.id,
   138→          leadId: comm.lead_id,
   139→          outcome: comm.metadata?.outcome,
   140→          duration,
   141→          createdAt: comm.created_at,
   142→        });
   143→      }
   144→    } else {
   145→      totalSms++;
   146→      if (comm.direction === 'inbound') {
   147→        inboundSms++;
   148→      } else {
   149→        outboundSms++;
   150→      }
   151→    }
   152→  });
   153→
   154→  // Track conversion timing
   155→  (conversions || []).forEach(conv => {
   156→    const date = new Date(conv.updated_at);
   157→    const hour = date.getHours();
   158→    const dayName = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()];
   159→
   160→    hourlyConversions[hour] = (hourlyConversions[hour] || 0) + 1;
   161→    dailyConversions[dayName] = (dailyConversions[dayName] || 0) + 1;
   162→  });
   163→
   164→  // Find best hour and day
   165→  const bestHourEntry = Object.entries(hourlyConversions).sort((a, b) => b[1] - a[1])[0];
   166→  const bestDayEntry = Object.entries(dailyConversions).sort((a, b) => b[1] - a[1])[0];
   167→
   168→  // Calculate call scores average
   169→  const avgCallScore = callScores?.length > 0
   170→    ? callScores.reduce((sum, s) => sum + s.overall_score, 0) / callScores.length
   171→    : 0;
   172→
   173→  const avgTalkRatio = callScores?.filter(s => s.talk_ratio)?.length > 0
   174→    ? callScores.filter(s => s.talk_ratio).reduce((sum, s) => sum + s.talk_ratio, 0) / callScores.filter(s => s.talk_ratio).length
   175→    : 0;
   176→
   177→  // Conversion rate
   178→  const conversionCount = conversions?.length || 0;
   179→  const conversionRate = totalCalls > 0 ? (conversionCount / totalCalls) * 100 : 0;
   180→
   181→  return {
   182→    totalCalls,
   183→    totalSms,
   184→    inboundSms,
   185→    outboundSms,
   186→    totalCallDuration,
   187→    callsWithDuration,
   188→    avgCallDuration: callsWithDuration > 0 ? Math.round(totalCallDuration / callsWithDuration) : 0,
   189→    conversions: conversionCount,
   190→    conversionRate: Math.round(conversionRate * 10) / 10,
   191→    avgCallScore: Math.round(avgCallScore * 10) / 10,
   192→    callsScored: callScores?.length || 0,
   193→    avgTalkRatio: Math.round(avgTalkRatio),
   194→    objectionsCount,
   195→    bestHour: bestHourEntry ? parseInt(bestHourEntry[0]) : null,
   196→    bestDay: bestDayEntry ? bestDayEntry[0] : null,
   197→    topCalls: topCalls.slice(0, 3),
   198→    conversionsList: conversions || [],
   199→  };
   200→}
   201→
   202→/**
   203→ * Get previous week stats for comparison
   204→ */
   205→async function getPreviousWeekStats(userId, currentWeekStart) {
   206→  const prevWeekStart = new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
   207→  const prevWeekEnd = currentWeekStart;
   208→
   209→  return aggregateRepWeeklyStats(userId, prevWeekStart, prevWeekEnd);
   210→}
   211→
   212→/**
   213→ * Analyze rep strengths and weaknesses using AI
   214→ */
   215→async function analyzeRepPerformance(stats, repName, prevStats = null) {
   216→  // Build context for AI
   217→  const context = `
   218→Rep: ${repName}
   219→This Week:
   220→- Calls: ${stats.totalCalls}
   221→- Conversions: ${stats.conversions} (${stats.conversionRate}% rate)
   222→- Avg Call Duration: ${formatDuration(stats.avgCallDuration)}
   223→- Talk Ratio: ${stats.avgTalkRatio}%
   224→- Objections Faced: ${stats.objectionsCount}
   225→- Best Time: ${stats.bestHour ? formatHour(stats.bestHour) : 'N/A'}
   226→- Best Day: ${stats.bestDay || 'N/A'}
   227→
   228→${prevStats ? `
   229→Previous Week:
   230→- Calls: ${prevStats.totalCalls}
   231→- Conversions: ${prevStats.conversions} (${prevStats.conversionRate}% rate)
   232→` : ''}
   233→`;
   234→
   235→  try {
   236→    const response = await callOpenAI([
   237→      {
   238→        role: 'system',
   239→        content: `You're analyzing sales rep performance for a contractor/worker recruitment company. Be specific and actionable. Keep each strength/weakness to one short sentence. Format response as JSON.`
   240→      },
   241→      {
   242→        role: 'user',
   243→        content: `Analyze this rep's performance and provide coaching tips:
   244→
   245→${context}
   246→
   247→Respond in JSON format:
   248→{
   249→  "strengths": ["strength 1", "strength 2"],
   250→  "weaknesses": ["weakness 1"],
   251→  "coaching_tips": ["specific actionable tip 1", "tip 2"]
   252→}
   253→
   254→Base your analysis on:
   255→- Conversion rate (good if >10%, great if >15%)
   256→- Call volume (more calls = more chances)
   257→- Talk ratio (ideal is 30-40% rep talking, 60-70% lead talking)
   258→- Call duration (2-4 min is ideal)
   259→- Improvement vs last week`
   260→      }
   261→    ], { model: 'gpt-4o-mini', temperature: 0.7, max_tokens: 500 });
   262→
   263→    const parsed = JSON.parse(response);
   264→    return {
   265→      strengths: parsed.strengths || [],
   266→      weaknesses: parsed.weaknesses || [],
   267→      coachingTips: parsed.coaching_tips || [],
   268→    };
   269→  } catch (error) {
   270→    console.error('[RepPerformance] AI analysis failed:', error.message);
   271→    // Fallback analysis
   272→    return generateFallbackAnalysis(stats, prevStats);
   273→  }
   274→}
   275→
   276→/**
   277→ * Generate fallback analysis without AI
   278→ */
   279→function generateFallbackAnalysis(stats, prevStats) {
   280→  const strengths = [];
   281→  const weaknesses = [];
   282→  const coachingTips = [];
   283→
   284→  // Analyze conversion rate
   285→  if (stats.conversionRate >= 15) {
   286→    strengths.push(`Excellent conversion rate (${stats.conversionRate}%)`);
   287→  } else if (stats.conversionRate >= 10) {
   288→    strengths.push(`Good conversion rate (${stats.conversionRate}%)`);
   289→  } else if (stats.conversionRate < 5 && stats.totalCalls > 10) {
   290→    weaknesses.push(`Low conversion rate (${stats.conversionRate}%) - review call approach`);
   291→    coachingTips.push('Listen to top performers\' calls for techniques');
   292→  }
   293→
   294→  // Analyze volume
   295→  if (stats.totalCalls >= 50) {
   296→    strengths.push('High call volume - great activity');
   297→  } else if (stats.totalCalls < 20) {
   298→    weaknesses.push('Low call volume - more dials needed');
   299→    coachingTips.push('Aim for 10+ calls per day');
   300→  }
   301→
   302→  // Analyze talk ratio
   303→  if (stats.avgTalkRatio > 50) {
   304→    weaknesses.push(`Talking too much (${stats.avgTalkRatio}%) - let leads speak more`);
   305→    coachingTips.push('Ask open questions and pause after');
   306→  } else if (stats.avgTalkRatio > 0 && stats.avgTalkRatio <= 40) {
   307→    strengths.push('Good listening skills - letting leads talk');
   308→  }
   309→
   310→  // Analyze improvement
   311→  if (prevStats && stats.conversionRate > prevStats.conversionRate) {
   312→    const improvement = stats.conversionRate - prevStats.conversionRate;
   313→    strengths.push(`Improving! +${improvement.toFixed(1)}% vs last week`);
   314→  }
   315→
   316→  // Best time coaching
   317→  if (stats.bestHour) {
   318→    coachingTips.push(`Your best conversion time is ${formatHour(stats.bestHour)} - book important calls then`);
   319→  }
   320→
   321→  return { strengths, weaknesses, coachingTips };
   322→}
   323→
   324→/**
   325→ * Score a single call using AI
   326→ */
   327→async function scoreCall(communication, transcript) {
   328→  if (!transcript || transcript.length < 50) {
   329→    return null;
   330→  }
   331→
   332→  try {
   333→    const response = await callOpenAI([
   334→      {
   335→        role: 'system',
   336→        content: `You're a sales call quality analyzer for a contractor/worker recruitment company. Score calls on a 1-10 scale. Be constructive but honest.`
   337→      },
   338→      {
   339→        role: 'user',
   340→        content: `Score this sales call transcript:
   341→
   342→${transcript.substring(0, 3000)}
   343→
   344→Respond in JSON:
   345→{
   346→  "overall_score": 7.5,
   347→  "opening_score": 8,
   348→  "rapport_score": 7,
   349→  "discovery_score": 6,
   350→  "objection_score": 7,
   351→  "closing_score": 8,
   352→  "strengths": ["specific strength 1", "strength 2"],
   353→  "improvements": ["specific improvement 1", "improvement 2"],
   354→  "coaching_note": "One sentence actionable coaching tip"
   355→}
   356→
   357→Scoring guide:
   358→- 9-10: Exceptional, textbook call
   359→- 7-8: Good, minor improvements
   360→- 5-6: Average, clear gaps
   361→- 3-4: Below average, needs coaching
   362→- 1-2: Poor, significant issues`
   363→      }
   364→    ], { model: 'gpt-4o-mini', temperature: 0.3, max_tokens: 400 });
   365→
   366→    return JSON.parse(response);
   367→  } catch (error) {
   368→    console.error('[RepPerformance] Call scoring failed:', error.message);
   369→    return null;
   370→  }
   371→}
   372→
   373→/**
   374→ * Save call quality score to database
   375→ */
   376→async function saveCallScore(communicationId, leadId, userId, scoreData, outcome, duration) {
   377→  const db = supabaseAdmin || supabase;
   378→  if (!db || !scoreData) return;
   379→
   380→  try {
   381→    await db.from('call_quality_scores').insert({
   382→      communication_id: communicationId,
   383→      lead_id: leadId,
   384→      user_id: userId,
   385→      overall_score: scoreData.overall_score,
   386→      opening_score: scoreData.opening_score,
   387→      rapport_score: scoreData.rapport_score,
   388→      discovery_score: scoreData.discovery_score,
   389→      objection_score: scoreData.objection_score,
   390→      closing_score: scoreData.closing_score,
   391→      call_duration_seconds: duration,
   392→      strengths: scoreData.strengths || [],
   393→      improvements: scoreData.improvements || [],
   394→      coaching_note: scoreData.coaching_note,
   395→      call_outcome: outcome,
   396→      led_to_conversion: outcome === 'converted',
   397→    });
   398→  } catch (error) {
   399→    console.error('[RepPerformance] Failed to save call score:', error.message);
   400→  }
   401→}
   402→
   403→/**
   404→ * Save weekly snapshot to database
   405→ */
   406→async function saveWeeklySnapshot(userId, displayName, weekStart, weekEnd, stats, analysis, prevStats) {
   407→  const db = supabaseAdmin || supabase;
   408→  if (!db) return;
   409→
   410→  // Calculate changes from previous week
   411→  const callsChangePct = prevStats?.totalCalls > 0
   412→    ? ((stats.totalCalls - prevStats.totalCalls) / prevStats.totalCalls * 100)
   413→    : null;
   414→
   415→  const conversionRateChange = prevStats
   416→    ? (stats.conversionRate - prevStats.conversionRate)
   417→    : null;
   418→
   419→  try {
   420→    await db.from('rep_weekly_snapshots').upsert({
   421→      user_id: userId,
   422→      display_name: displayName,
   423→      week_start: weekStart.toISOString().split('T')[0],
   424→      week_end: weekEnd.toISOString().split('T')[0],
   425→      total_calls: stats.totalCalls,
   426→      total_sms: stats.totalSms,
   427→      inbound_sms: stats.inboundSms,
   428→      outbound_sms: stats.outboundSms,
   429→      avg_call_duration_seconds: stats.avgCallDuration,
   430→      total_call_duration_seconds: stats.totalCallDuration,
   431→      calls_with_duration: stats.callsWithDuration,
   432→      conversions: stats.conversions,
   433→      conversion_rate: stats.conversionRate,
   434→      avg_call_score: stats.avgCallScore,
   435→      calls_scored: stats.callsScored,
   436→      avg_talk_ratio: stats.avgTalkRatio,
   437→      objections_faced: stats.objectionsCount,
   438→      best_hour: stats.bestHour,
   439→      best_day: stats.bestDay,
   440→      calls_change_pct: callsChangePct ? Math.round(callsChangePct * 10) / 10 : null,
   441→      conversion_rate_change: conversionRateChange ? Math.round(conversionRateChange * 10) / 10 : null,
   442→      strengths: analysis.strengths,
   443→      weaknesses: analysis.weaknesses,
   444→      coaching_tips: analysis.coachingTips,
   445→      top_calls: stats.topCalls,
   446→    }, {
   447→      onConflict: 'user_id,week_start',
   448→    });
   449→
   450→    console.log(`[RepPerformance] Saved snapshot for ${displayName}`);
   451→  } catch (error) {
   452→    console.error('[RepPerformance] Failed to save snapshot:', error.message);
   453→  }
   454→}
   455→
   456→/**
   457→ * Generate full weekly report for all reps
   458→ */
   459→async function generateWeeklyRepReport() {
   460→  console.log('[RepPerformance] Generating weekly rep report...');
   461→
   462→  const weekEnd = new Date();
   463→  const weekStart = getWeekStartAEST(weekEnd);
   464→
   465→  console.log(`[RepPerformance] Week range: ${weekStart.toISOString()} to ${weekEnd.toISOString()}`);
   466→
   467→  // Get all reps
   468→  const reps = await getAllReps();
   469→  console.log(`[RepPerformance] Found ${reps.length} reps`);
   470→
   471→  if (reps.length === 0) {
   472→    console.log('[RepPerformance] No reps found');
   473→    return { success: false, reason: 'No reps found' };
   474→  }
   475→
   476→  const repReports = [];
   477→
   478→  for (const rep of reps) {
   479→    try {
   480→      // Get current week stats
   481→      const stats = await aggregateRepWeeklyStats(rep.user_id, weekStart, weekEnd);
   482→      if (!stats || stats.totalCalls === 0) {
   483→        console.log(`[RepPerformance] Skipping ${rep.display_name} - no activity`);
   484→        continue;
   485→      }
   486→
   487→      // Get previous week for comparison
   488→      const prevStats = await getPreviousWeekStats(rep.user_id, weekStart);
   489→
   490→      // AI analysis
   491→      const analysis = await analyzeRepPerformance(stats, rep.display_name, prevStats);
   492→
   493→      // Save snapshot
   494→      await saveWeeklySnapshot(rep.user_id, rep.display_name, weekStart, weekEnd, stats, analysis, prevStats);
   495→
   496→      repReports.push({
   497→        userId: rep.user_id,
   498→        name: rep.display_name || rep.user_id,
   499→        level: rep.level || 1,
   500→        stats,
   501→        prevStats,
   502→        analysis,
   503→      });
   504→
   505→    } catch (error) {
   506→      console.error(`[RepPerformance] Error processing ${rep.display_name}:`, error.message);
   507→    }
   508→  }
   509→
   510→  // Sort by conversion rate
   511→  repReports.sort((a, b) => b.stats.conversionRate - a.stats.conversionRate);
   512→
   513→  // Identify team highlights
   514→  const topPerformer = repReports[0];
   515→  const mostActive = repReports.slice().sort((a, b) => b.stats.totalCalls - a.stats.totalCalls)[0];
   516→  const mostImproved = repReports
   517→    .filter(r => r.prevStats && r.prevStats.conversionRate > 0)
   518→    .sort((a, b) => {
   519→      const aImprovement = a.stats.conversionRate - a.prevStats.conversionRate;
   520→      const bImprovement = b.stats.conversionRate - b.prevStats.conversionRate;
   521→      return bImprovement - aImprovement;
   522→    })[0];
   523→
   524→  console.log(`[RepPerformance] Generated reports for ${repReports.length} reps`);
   525→
   526→  return {
   527→    success: true,
   528→    weekStart: weekStart.toISOString().split('T')[0],
   529→    weekEnd: weekEnd.toISOString().split('T')[0],
   530→    repsChecked: reps.length,
   531→    reps: repReports,
   532→    highlights: {
   533→      topPerformer: topPerformer ? {
   534→        name: topPerformer.name,
   535→        conversionRate: topPerformer.stats.conversionRate,
   536→      } : null,
   537→      mostActive: mostActive ? {
   538→        name: mostActive.name,
   539→        calls: mostActive.stats.totalCalls,
   540→      } : null,
   541→      mostImproved: mostImproved ? {
   542→        name: mostImproved.name,
   543→        improvement: Math.round((mostImproved.stats.conversionRate - mostImproved.prevStats.conversionRate) * 10) / 10,
   544→      } : null,
   545→    },
   546→    teamTotals: {
   547→      calls: repReports.reduce((sum, r) => sum + r.stats.totalCalls, 0),
   548→      conversions: repReports.reduce((sum, r) => sum + r.stats.conversions, 0),
   549→      avgConversionRate: repReports.length > 0
   550→        ? Math.round(repReports.reduce((sum, r) => sum + r.stats.conversionRate, 0) / repReports.length * 10) / 10
   551→        : 0,
   552→    },
   553→  };
   554→}
   555→
   556→/**
   557→ * Format duration in seconds to human readable
   558→ */
   559→function formatDuration(seconds) {
   560→  if (!seconds) return '0s';
   561→  const mins = Math.floor(seconds / 60);
   562→  const secs = seconds % 60;
   563→  if (mins === 0) return `${secs}s`;
   564→  return `${mins}m ${secs}s`;
   565→}
   566→
   567→/**
   568→ * Format hour to AM/PM
   569→ */
   570→function formatHour(hour) {
   571→  if (hour === 0) return '12am';
   572→  if (hour < 12) return `${hour}am`;
   573→  if (hour === 12) return '12pm';
   574→  return `${hour - 12}pm`;
   575→}
   576→
   577→module.exports = {
   578→  generateWeeklyRepReport,
   579→  aggregateRepWeeklyStats,
   580→  analyzeRepPerformance,
   581→  scoreCall,
   582→  saveCallScore,
   583→  saveWeeklySnapshot,
   584→  getAllReps,
   585→  formatDuration,
   586→  formatHour,
   587→};
   588→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Compliance Service
     3→ * Validates messages against brand guidelines and provides auto-fix suggestions
     4→ */
     5→
     6→const { supabase } = require('../config/database');
     7→// Note: callOpenAI is lazy-loaded in checkToneWithAI to avoid circular dependency
     8→
     9→// Cache for brand guidelines (2 hour TTL)
    10→let guidelinesCache = null;
    11→let guidelinesCacheTime = null;
    12→const CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours
    13→
    14→/**
    15→ * Get active brand guidelines (cached)
    16→ */
    17→async function getGuidelines() {
    18→  const now = Date.now();
    19→
    20→  if (guidelinesCache && guidelinesCacheTime && (now - guidelinesCacheTime < CACHE_TTL)) {
    21→    return guidelinesCache;
    22→  }
    23→
    24→  try {
    25→    const { data, error } = await supabase
    26→      .from('brand_guidelines')
    27→      .select('*')
    28→      .eq('is_active', true)
    29→      .order('version', { ascending: false })
    30→      .limit(1)
    31→      .single();
    32→
    33→    if (error && error.code !== 'PGRST116') {
    34→      console.error('Error fetching guidelines:', error);
    35→      return getDefaultGuidelines();
    36→    }
    37→
    38→    guidelinesCache = data || getDefaultGuidelines();
    39→    guidelinesCacheTime = now;
    40→
    41→    return guidelinesCache;
    42→  } catch (err) {
    43→    console.error('Guidelines fetch error:', err);
    44→    return getDefaultGuidelines();
    45→  }
    46→}
    47→
    48→/**
    49→ * Default guidelines if database is unavailable
    50→ */
    51→function getDefaultGuidelines() {
    52→  return {
    53→    tone_description: 'Friendly, direct, Aussie tradie vibes. Not corporate, not salesy.',
    54→    tone_keywords: ['mate', 'hey', 'cheers', 'no worries', 'legend'],
    55→    banned_words: ['guaranteed', 'promise', 'best', 'cheapest', 'urgent', 'final', 'hurry', 'exclusive'],
    56→    banned_phrases: ['act now', 'limited time', 'don\'t miss out', 'once in a lifetime', 'last chance'],
    57→    required_signature: 'Cheers, {sender_name} - RateRight',
    58→    must_include_cta: true,
    59→    max_exclamation_marks: 1
    60→  };
    61→}
    62→
    63→/**
    64→ * Clear guidelines cache (call after updates)
    65→ */
    66→function clearGuidelinesCache() {
    67→  guidelinesCache = null;
    68→  guidelinesCacheTime = null;
    69→}
    70→
    71→/**
    72→ * Check message compliance against brand guidelines
    73→ * @param {string} message - The message to check
    74→ * @param {Object} options - Check options
    75→ * @returns {Object} Compliance result
    76→ */
    77→async function checkCompliance(message, options = {}) {
    78→  const {
    79→    skipToneCheck = false,
    80→    checkLength = true,
    81→    maxLength = 320  // SMS limit
    82→  } = options;
    83→
    84→  const guidelines = await getGuidelines();
    85→  const issues = [];
    86→  let score = 100;
    87→
    88→  // 1. Check for banned words
    89→  const bannedWordsFound = findBannedWords(message, guidelines.banned_words || []);
    90→  if (bannedWordsFound.length > 0) {
    91→    bannedWordsFound.forEach(found => {
    92→      issues.push({
    93→        type: 'banned_word',
    94→        severity: 'error',
    95→        message: `Contains banned word: "${found.word}"`,
    96→        position: { start: found.start, end: found.end },
    97→        suggestion: getSuggestionForBannedWord(found.word)
    98→      });
    99→      score -= 15;
   100→    });
   101→  }
   102→
   103→  // 2. Check for banned phrases
   104→  const bannedPhrasesFound = findBannedPhrases(message, guidelines.banned_phrases || []);
   105→  if (bannedPhrasesFound.length > 0) {
   106→    bannedPhrasesFound.forEach(found => {
   107→      issues.push({
   108→        type: 'banned_phrase',
   109→        severity: 'error',
   110→        message: `Contains banned phrase: "${found.phrase}"`,
   111→        position: { start: found.start, end: found.end },
   112→        suggestion: 'Remove this phrase or rephrase without urgency/pressure tactics'
   113→      });
   114→      score -= 20;
   115→    });
   116→  }
   117→
   118→  // 3. Check exclamation marks
   119→  const exclamationCount = (message.match(/!/g) || []).length;
   120→  const maxExclamations = guidelines.max_exclamation_marks || 1;
   121→  if (exclamationCount > maxExclamations) {
   122→    issues.push({
   123→      type: 'exclamation_marks',
   124→      severity: 'warning',
   125→      message: `Too many exclamation marks (${exclamationCount} found, max ${maxExclamations})`,
   126→      suggestion: 'Reduce exclamation marks to sound more conversational'
   127→    });
   128→    score -= 5;
   129→  }
   130→
   131→  // 4. Check message length (for SMS)
   132→  if (checkLength && message.length > maxLength) {
   133→    issues.push({
   134→      type: 'length',
   135→      severity: 'warning',
   136→      message: `Message too long (${message.length} chars, max ${maxLength})`,
   137→      suggestion: 'Shorten the message to fit SMS limits'
   138→    });
   139→    score -= 10;
   140→  }
   141→
   142→  // 5. Check for ALL CAPS words (3+ letters)
   143→  const capsWords = message.match(/\b[A-Z]{3,}\b/g) || [];
   144→  if (capsWords.length > 1) {
   145→    issues.push({
   146→      type: 'caps',
   147→      severity: 'warning',
   148→      message: `Too much CAPS: ${capsWords.join(', ')}`,
   149→      suggestion: 'Reduce CAPS to avoid sounding aggressive'
   150→    });
   151→    score -= 5;
   152→  }
   153→
   154→  // 6. Check for pressure tactics (AI-assisted)
   155→  if (!skipToneCheck && message.length > 20) {
   156→    const pressureTacticsFound = detectPressureTactics(message);
   157→    if (pressureTacticsFound.length > 0) {
   158→      pressureTacticsFound.forEach(tactic => {
   159→        issues.push({
   160→          type: 'pressure_tactic',
   161→          severity: 'error',
   162→          message: `Possible pressure tactic: "${tactic}"`,
   163→          suggestion: 'Rephrase without creating false urgency'
   164→        });
   165→        score -= 15;
   166→      });
   167→    }
   168→  }
   169→
   170→  // 7. Check for missing CTA (if required)
   171→  if (guidelines.must_include_cta && !hasCTA(message)) {
   172→    issues.push({
   173→      type: 'missing_cta',
   174→      severity: 'info',
   175→      message: 'No clear call-to-action found',
   176→      suggestion: 'Add a question or next step for the recipient'
   177→    });
   178→    score -= 5;
   179→  }
   180→
   181→  // Ensure score doesn't go below 0
   182→  score = Math.max(0, score);
   183→
   184→  // Determine if passed (score >= 70 and no errors)
   185→  const hasErrors = issues.some(i => i.severity === 'error');
   186→  const passed = score >= 70 && !hasErrors;
   187→
   188→  // Try auto-fix if there are issues
   189→  let autoFixed = null;
   190→  if (!passed && issues.length > 0) {
   191→    autoFixed = attemptAutoFix(message, issues, guidelines);
   192→  }
   193→
   194→  return {
   195→    passed,
   196→    score,
   197→    issues,
   198→    suggestions: issues.map(i => i.suggestion).filter(Boolean),
   199→    autoFixed,
   200→    guidelines: {
   201→      tone: guidelines.tone_description,
   202→      bannedWordsCount: guidelines.banned_words?.length || 0
   203→    }
   204→  };
   205→}
   206→
   207→/**
   208→ * Find banned words in message
   209→ */
   210→function findBannedWords(message, bannedWords) {
   211→  const found = [];
   212→  const lowerMessage = message.toLowerCase();
   213→
   214→  bannedWords.forEach(word => {
   215→    const lowerWord = word.toLowerCase();
   216→    const regex = new RegExp(`\\b${escapeRegExp(lowerWord)}\\b`, 'gi');
   217→    let match;
   218→    while ((match = regex.exec(lowerMessage)) !== null) {
   219→      found.push({
   220→        word,
   221→        start: match.index,
   222→        end: match.index + word.length
   223→      });
   224→    }
   225→  });
   226→
   227→  return found;
   228→}
   229→
   230→/**
   231→ * Find banned phrases in message
   232→ */
   233→function findBannedPhrases(message, bannedPhrases) {
   234→  const found = [];
   235→  const lowerMessage = message.toLowerCase();
   236→
   237→  bannedPhrases.forEach(phrase => {
   238→    const lowerPhrase = phrase.toLowerCase();
   239→    let index = lowerMessage.indexOf(lowerPhrase);
   240→    while (index !== -1) {
   241→      found.push({
   242→        phrase,
   243→        start: index,
   244→        end: index + phrase.length
   245→      });
   246→      index = lowerMessage.indexOf(lowerPhrase, index + 1);
   247→    }
   248→  });
   249→
   250→  return found;
   251→}
   252→
   253→/**
   254→ * Detect pressure tactics using patterns
   255→ */
   256→function detectPressureTactics(message) {
   257→  const patterns = [
   258→    /only \d+ (left|remaining|available)/i,
   259→    /offer (ends|expires) (today|tomorrow|soon)/i,
   260→    /before it's too late/i,
   261→    /won't last/i,
   262→    /final (offer|chance|warning)/i,
   263→    /act (now|fast|quickly)/i,
   264→    /don't delay/i,
   265→    /time is running out/i,
   266→    /while (stocks|supplies) last/i
   267→  ];
   268→
   269→  const found = [];
   270→  patterns.forEach(pattern => {
   271→    const match = message.match(pattern);
   272→    if (match) {
   273→      found.push(match[0]);
   274→    }
   275→  });
   276→
   277→  return found;
   278→}
   279→
   280→/**
   281→ * Check if message has a call-to-action
   282→ */
   283→function hasCTA(message) {
   284→  const ctaPatterns = [
   285→    /\?$/,  // Ends with question
   286→    /want me to/i,
   287→    /let me know/i,
   288→    /give me a call/i,
   289→    /reply/i,
   290→    /click here/i,
   291→    /check out/i,
   292→    /take a look/i,
   293→    /what do you think/i,
   294→    /interested\?/i,
   295→    /can I help/i,
   296→    /any questions/i,
   297→    /sound good/i,
   298→    /fair\?/i
   299→  ];
   300→
   301→  return ctaPatterns.some(pattern => pattern.test(message));
   302→}
   303→
   304→/**
   305→ * Get suggestion for replacing banned word
   306→ */
   307→function getSuggestionForBannedWord(word) {
   308→  const suggestions = {
   309→    'guaranteed': 'Try: "we can help you" or "you\'ll likely see"',
   310→    'promise': 'Try: "we aim to" or "our goal is"',
   311→    'best': 'Try: "great" or "solid"',
   312→    'cheapest': 'Try: "competitive" or "fair"',
   313→    'free': 'For contractors: "no cost to you". For workers: mention the 9.9% fee',
   314→    'urgent': 'Try: "when you get a chance" or "worth looking at"',
   315→    'final': 'Try: "following up" or "checking in"',
   316→    'hurry': 'Try: "when you\'re ready" or "no rush"',
   317→    'exclusive': 'Try: "available to you" or "you can access"'
   318→  };
   319→
   320→  return suggestions[word.toLowerCase()] || `Consider removing or rephrasing "${word}"`;
   321→}
   322→
   323→/**
   324→ * Attempt to auto-fix minor issues
   325→ */
   326→function attemptAutoFix(message, issues, guidelines) {
   327→  let fixed = message;
   328→  let canAutoFix = true;
   329→
   330→  // Only auto-fix if all issues are fixable
   331→  const unfixableTypes = ['pressure_tactic', 'tone'];
   332→  if (issues.some(i => i.severity === 'error' && unfixableTypes.includes(i.type))) {
   333→    canAutoFix = false;
   334→  }
   335→
   336→  if (!canAutoFix) {
   337→    return null;
   338→  }
   339→
   340→  // Fix exclamation marks
   341→  const maxExclamations = guidelines.max_exclamation_marks || 1;
   342→  const exclamationMatches = fixed.match(/!+/g) || [];
   343→  let exclamationCount = exclamationMatches.reduce((sum, m) => sum + m.length, 0);
   344→
   345→  while (exclamationCount > maxExclamations) {
   346→    // Replace double exclamation with single
   347→    fixed = fixed.replace(/!!/, '!');
   348→    // Then remove trailing exclamations if still over
   349→    if ((fixed.match(/!/g) || []).length > maxExclamations) {
   350→      // Remove the last exclamation
   351→      const lastIndex = fixed.lastIndexOf('!');
   352→      if (lastIndex > -1) {
   353→        fixed = fixed.substring(0, lastIndex) + fixed.substring(lastIndex + 1);
   354→      }
   355→    }
   356→    exclamationCount = (fixed.match(/!/g) || []).length;
   357→  }
   358→
   359→  // Replace banned words with alternatives (simple replacements)
   360→  const simpleReplacements = {
   361→    'guaranteed': 'likely',
   362→    'promise': 'aim to',
   363→    'best': 'great',
   364→    'cheapest': 'competitive',
   365→    'urgent': 'when you get a chance',
   366→    'hurry': 'when you\'re ready'
   367→  };
   368→
   369→  Object.entries(simpleReplacements).forEach(([banned, replacement]) => {
   370→    const regex = new RegExp(`\\b${banned}\\b`, 'gi');
   371→    fixed = fixed.replace(regex, replacement);
   372→  });
   373→
   374→  // Return null if nothing changed
   375→  if (fixed === message) {
   376→    return null;
   377→  }
   378→
   379→  return fixed;
   380→}
   381→
   382→/**
   383→ * Auto-fix a message (best effort)
   384→ */
   385→async function autoFixMessage(message) {
   386→  const result = await checkCompliance(message);
   387→
   388→  if (result.passed) {
   389→    return {
   390→      original: message,
   391→      fixed: message,
   392→      changed: false,
   393→      issues: []
   394→    };
   395→  }
   396→
   397→  if (result.autoFixed) {
   398→    return {
   399→      original: message,
   400→      fixed: result.autoFixed,
   401→      changed: true,
   402→      issues: result.issues
   403→    };
   404→  }
   405→
   406→  // If auto-fix failed, return original with issues
   407→  return {
   408→    original: message,
   409→    fixed: null,
   410→    changed: false,
   411→    issues: result.issues,
   412→    requiresManualReview: true
   413→  };
   414→}
   415→
   416→/**
   417→ * Validate a script in the database
   418→ */
   419→async function validateScript(scriptId) {
   420→  try {
   421→    const { data: script, error } = await supabase
   422→      .from('scripts')
   423→      .select('id, name, content, category')
   424→      .eq('id', scriptId)
   425→      .single();
   426→
   427→    if (error || !script) {
   428→      return { success: false, error: 'Script not found' };
   429→    }
   430→
   431→    if (!script.content) {
   432→      return {
   433→        success: true,
   434→        scriptId,
   435→        compliance: { passed: true, score: 100, issues: [], message: 'No content to validate (legacy script)' }
   436→      };
   437→    }
   438→
   439→    const compliance = await checkCompliance(script.content, {
   440→      skipToneCheck: script.category === 'call' // Call scripts may have different tone needs
   441→    });
   442→
   443→    // Update script compliance status
   444→    const newStatus = compliance.passed ? 'approved' :
   445→                      compliance.issues.some(i => i.severity === 'error') ? 'needs_review' : 'pending';
   446→
   447→    await supabase
   448→      .from('scripts')
   449→      .update({
   450→        compliance_status: newStatus,
   451→        compliance_checked_at: new Date().toISOString(),
   452→        compliance_issues: compliance.issues
   453→      })
   454→      .eq('id', scriptId);
   455→
   456→    return {
   457→      success: true,
   458→      scriptId,
   459→      name: script.name,
   460→      compliance
   461→    };
   462→  } catch (err) {
   463→    console.error('Script validation error:', err);
   464→    return { success: false, error: err.message };
   465→  }
   466→}
   467→
   468→/**
   469→ * Batch validate multiple scripts
   470→ */
   471→async function batchValidate(scriptIds) {
   472→  const results = [];
   473→
   474→  for (const id of scriptIds) {
   475→    const result = await validateScript(id);
   476→    results.push(result);
   477→  }
   478→
   479→  const passed = results.filter(r => r.compliance?.passed).length;
   480→  const failed = results.filter(r => r.compliance && !r.compliance.passed).length;
   481→
   482→  return {
   483→    total: results.length,
   484→    passed,
   485→    failed,
   486→    results
   487→  };
   488→}
   489→
   490→/**
   491→ * Add message to compliance review queue
   492→ */
   493→async function addToReviewQueue(content, options = {}) {
   494→  const {
   495→    sourceType = 'manual',
   496→    sourceId = null,
   497→    leadId = null,
   498→    issues = [],
   499→    suggestedFix = null
   500→  } = options;
   501→
   502→  try {
   503→    const { data, error } = await supabase
   504→      .from('compliance_queue')
   505→      .insert({
   506→        source_type: sourceType,
   507→        source_id: sourceId,
   508→        lead_id: leadId,
   509→        original_content: content,
   510→        suggested_fix: suggestedFix,
   511→        issues,
   512→        status: 'pending'
   513→      })
   514→      .select()
   515→      .single();
   516→
   517→    if (error) {
   518→      console.error('Error adding to review queue:', error);
   519→      return { success: false, error: error.message };
   520→    }
   521→
   522→    return { success: true, queueItem: data };
   523→  } catch (err) {
   524→    console.error('Review queue error:', err);
   525→    return { success: false, error: err.message };
   526→  }
   527→}
   528→
   529→/**
   530→ * Check tone using AI (GPT-4o-mini for cost efficiency)
   531→ */
   532→async function checkToneWithAI(message, guidelines) {
   533→  try {
   534→    // Lazy-load to avoid circular dependency with ai.js
   535→    const { callOpenAI } = require('./ai');
   536→
   537→    const prompt = `Analyze this message for brand voice compliance:
   538→
   539→MESSAGE: "${message}"
   540→
   541→BRAND VOICE RULES:
   542→- Tone: ${guidelines.tone_description}
   543→- Should use words like: ${(guidelines.tone_keywords || []).join(', ')}
   544→- Should NOT use: ${(guidelines.banned_words || []).join(', ')}
   545→
   546→Rate the tone match from 0-100 and list any issues.
   547→Respond in JSON format: { "score": number, "issues": ["issue1", "issue2"], "suggestions": ["suggestion1"] }`;
   548→
   549→    const response = await callOpenAI(prompt, { model: 'gpt-4o-mini', maxTokens: 200 });
   550→
   551→    try {
   552→      return JSON.parse(response);
   553→    } catch {
   554→      return { score: 70, issues: [], suggestions: [] };
   555→    }
   556→  } catch (err) {
   557→    console.error('AI tone check error:', err);
   558→    return { score: 70, issues: [], suggestions: [] };
   559→  }
   560→}
   561→
   562→/**
   563→ * Escape special regex characters
   564→ */
   565→function escapeRegExp(string) {
   566→  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
   567→}
   568→
   569→module.exports = {
   570→  getGuidelines,
   571→  clearGuidelinesCache,
   572→  checkCompliance,
   573→  autoFixMessage,
   574→  validateScript,
   575→  batchValidate,
   576→  addToReviewQueue,
   577→  checkToneWithAI
   578→};
   579→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


Let me continue reading the remaining files. I'll process several more batches.


TodoWrite

Audit batch 1: challengesServiceV2.js, aiCallScoring.js, activationAI.js, challengesService.js

Auditing batch 1: challengesServiceV2, aiCallScoring, activationAI, challengesService

Audit batch 2: badgesService.js, callListRanker.js, callAnalytics.js, promptManagement.js

Auditing batch 2: badgesService, callListRanker, callAnalytics, promptManagement

Audit batch 3: learning.js, duelsService.js, dealIntelligence.js, selfImprovement.js

Auditing batch 3: learning, duelsService, dealIntelligence, selfImprovement

Audit batch 4: platformSync.js, perplexity.js, repPerformance.js, compliance.js

Auditing batch 4: platformSync, perplexity, repPerformance, compliance

Audit batch 5: openai.js, criticalAlerts.js, leaderboardService.js, deepgram.js

Auditing batch 5: openai, criticalAlerts, leaderboardService, deepgram

Audit batch 6: scoring.js, pushNotifications.js, weeklyIntelligence.js

Auditing batch 6: scoring, pushNotifications, weeklyIntelligence

Audit batch 7: twilioMedia.js, twilioMediaSetup.js, templates.js, slack.js

Auditing batch 7: twilioMedia, twilioMediaSetup, templates, slack

Audit batch 8: xpService.js, wisdom.js, stageDiagnosis.js, stageCalculator.js

Auditing batch 8: xpService, wisdom, stageDiagnosis, stageCalculator

Audit batch 9: twimlWithStream.js, qualityAudit.js, ai.js, notifications.js

Auditing batch 9: twimlWithStream, qualityAudit, ai, notifications

Audit batch 10: taskExecutor.js, taskParser.js, exportService.js, webhookDelivery.js

Auditing batch 10: taskExecutor, taskParser, exportService, webhookDelivery

Audit batch 11: enrichment.js, jobLocking.js, twilio.js, cache.js

Auditing batch 11: enrichment, jobLocking, twilio, cache

Compile comprehensive audit report


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable


     1→/**
     2→ * OpenAI Service
     3→ * Provides chat completion functionality for AI-powered features
     4→ */
     5→
     6→const OpenAI = require('openai');
     7→
     8→// Initialize OpenAI client (only if API key is available)
     9→let openai = null;
    10→if (process.env.OPENAI_API_KEY) {
    11→  openai = new OpenAI({
    12→    apiKey: process.env.OPENAI_API_KEY,
    13→  });
    14→} else {
    15→  console.warn('[OpenAI Service] OPENAI_API_KEY not set - AI features will be disabled');
    16→}
    17→
    18→/**
    19→ * Generate a chat completion using OpenAI
    20→ * @param {Array} messages - Array of chat messages with role and content
    21→ * @param {Object} options - Options for the completion
    22→ * @param {string} options.model - Model to use (default: gpt-4o-mini)
    23→ * @param {number} options.temperature - Temperature for generation (default: 0.7)
    24→ * @param {Object} options.response_format - Response format (e.g., { type: 'json_object' })
    25→ * @returns {string} The content of the assistant's response
    26→ */
    27→async function generateChatCompletion(messages, options = {}) {
    28→  if (!openai) {
    29→    throw new Error('OpenAI not configured - OPENAI_API_KEY not set');
    30→  }
    31→
    32→  const {
    33→    model = 'gpt-4o-mini',
    34→    temperature = 0.7,
    35→    response_format,
    36→    max_tokens,
    37→  } = options;
    38→
    39→  const requestOptions = {
    40→    model,
    41→    messages,
    42→    temperature,
    43→  };
    44→
    45→  if (response_format) {
    46→    requestOptions.response_format = response_format;
    47→  }
    48→
    49→  if (max_tokens) {
    50→    requestOptions.max_tokens = max_tokens;
    51→  }
    52→
    53→  const response = await openai.chat.completions.create(requestOptions);
    54→
    55→  // Return the content string from the first choice
    56→  return response.choices[0]?.message?.content || '';
    57→}
    58→
    59→/**
    60→ * Check if OpenAI is configured and available
    61→ * @returns {boolean}
    62→ */
    63→function isConfigured() {
    64→  return openai !== null;
    65→}
    66→
    67→module.exports = {
    68→  generateChatCompletion,
    69→  isConfigured,
    70→};
    71→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Critical Alerts Service
     3→ * Sends real-time Slack alerts for critical issues detected in usage patterns
     4→ *
     5→ * Monitors for:
     6→ * - Rage clicks (user frustration)
     7→ * - Error spikes
     8→ * - High abandonment rates
     9→ * - System performance issues
    10→ */
    11→
    12→const { sendSlackMessage } = require('./slack');
    13→const { supabase, supabaseAdmin } = require('../config/database');
    14→
    15→const db = supabaseAdmin || supabase;
    16→
    17→// Alert thresholds
    18→const THRESHOLDS = {
    19→  RAGE_CLICKS_PER_MINUTE: 3, // Alert if 3+ rage clicks in any 1-minute window
    20→  ERRORS_PER_5MIN: 10, // Alert if 10+ errors in 5 minutes
    21→  ABANDONMENT_RATE_SPIKE: 50, // Alert if abandonment rate > 50% in last hour
    22→  API_FAILURES_PER_5MIN: 5, // Alert if 5+ API failures in 5 minutes
    23→  SMS_FAILURE_RATE: 20, // Alert if >20% SMS delivery failures in last hour
    24→  SLOW_API_THRESHOLD_MS: 5000, // Alert if API response >5 seconds
    25→  SLOW_API_COUNT_PER_5MIN: 3, // Alert if 3+ slow responses in 5 minutes
    26→};
    27→
    28→// Track recent alerts to avoid spam (dedup window in ms)
    29→const recentAlerts = new Map();
    30→const ALERT_DEDUP_WINDOW = 30 * 60 * 1000; // 30 minutes
    31→
    32→// Periodic cleanup to prevent memory leak - runs every hour
    33→const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
    34→setInterval(() => {
    35→  const now = Date.now();
    36→  let cleaned = 0;
    37→  for (const [key, time] of recentAlerts.entries()) {
    38→    if ((now - time) > ALERT_DEDUP_WINDOW) {
    39→      recentAlerts.delete(key);
    40→      cleaned++;
    41→    }
    42→  }
    43→  if (cleaned > 0) {
    44→    console.log(`[CriticalAlerts] Cleaned ${cleaned} stale alert entries`);
    45→  }
    46→}, CLEANUP_INTERVAL);
    47→
    48→/**
    49→ * Check if we should send this alert (deduplication)
    50→ */
    51→function shouldSendAlert(alertKey) {
    52→  const lastSent = recentAlerts.get(alertKey);
    53→  const now = Date.now();
    54→
    55→  if (lastSent && (now - lastSent) < ALERT_DEDUP_WINDOW) {
    56→    return false;
    57→  }
    58→
    59→  recentAlerts.set(alertKey, now);
    60→
    61→  // Cleanup old entries
    62→  for (const [key, time] of recentAlerts.entries()) {
    63→    if ((now - time) > ALERT_DEDUP_WINDOW) {
    64→      recentAlerts.delete(key);
    65→    }
    66→  }
    67→
    68→  return true;
    69→}
    70→
    71→/**
    72→ * Send a critical alert to Slack
    73→ */
    74→async function sendCriticalAlert(title, details, severity = 'warning') {
    75→  const emoji = severity === 'critical' ? ':rotating_light:' : ':warning:';
    76→  const severityText = severity === 'critical' ? 'CRITICAL' : 'WARNING';
    77→
    78→  const message = {
    79→    text: `${emoji} *${severityText}: ${title}*\n${details}\n\n_Detected by 0.1% Intelligence System_`
    80→  };
    81→
    82→  try {
    83→    await sendSlackMessage(message);
    84→    console.log(`[CriticalAlerts] Sent ${severity} alert: ${title}`);
    85→  } catch (error) {
    86→    console.error('[CriticalAlerts] Failed to send alert:', error.message);
    87→  }
    88→}
    89→
    90→/**
    91→ * Check for rage click patterns
    92→ * Called after each rage_click event is logged
    93→ */
    94→async function checkRageClicks(page, userId) {
    95→  const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();
    96→
    97→  const { data: recentRageClicks, error } = await db
    98→    .from('usage_events')
    99→    .select('id, page, created_at')
   100→    .eq('event_type', 'rage_click')
   101→    .eq('page', page)
   102→    .gte('created_at', oneMinuteAgo);
   103→
   104→  if (error) {
   105→    console.error('[CriticalAlerts] Error checking rage clicks:', error);
   106→    return;
   107→  }
   108→
   109→  if (recentRageClicks && recentRageClicks.length >= THRESHOLDS.RAGE_CLICKS_PER_MINUTE) {
   110→    const alertKey = `rage_click_${page}`;
   111→
   112→    if (shouldSendAlert(alertKey)) {
   113→      await sendCriticalAlert(
   114→        'User Frustration Detected',
   115→        `*${recentRageClicks.length} rage clicks* on \`${page}\` in the last minute.\n` +
   116→        `This indicates significant user frustration with this page.\n\n` +
   117→        `*Action:* Investigate ${page} for usability issues.`,
   118→        'warning'
   119→      );
   120→
   121→      // Log to detected_friction table
   122→      await logFriction('rage_clicks', page, recentRageClicks.length, {
   123→        event_ids: recentRageClicks.map(e => e.id),
   124→        time_window: '1 minute'
   125→      });
   126→    }
   127→  }
   128→}
   129→
   130→/**
   131→ * Check for error spikes
   132→ * Called periodically or after errors are logged
   133→ */
   134→async function checkErrorSpike() {
   135→  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
   136→
   137→  const { data: recentErrors, error } = await db
   138→    .from('usage_events')
   139→    .select('id, page, event_name, context')
   140→    .eq('event_type', 'error')
   141→    .gte('created_at', fiveMinutesAgo);
   142→
   143→  if (error) {
   144→    console.error('[CriticalAlerts] Error checking error spike:', error);
   145→    return;
   146→  }
   147→
   148→  if (recentErrors && recentErrors.length >= THRESHOLDS.ERRORS_PER_5MIN) {
   149→    const alertKey = 'error_spike';
   150→
   151→    if (shouldSendAlert(alertKey)) {
   152→      // Group by page
   153→      const errorsByPage = {};
   154→      recentErrors.forEach(e => {
   155→        const page = e.page || 'unknown';
   156→        errorsByPage[page] = (errorsByPage[page] || 0) + 1;
   157→      });
   158→
   159→      const pageBreakdown = Object.entries(errorsByPage)
   160→        .sort((a, b) => b[1] - a[1])
   161→        .slice(0, 5)
   162→        .map(([page, count]) => `• ${page}: ${count}`)
   163→        .join('\n');
   164→
   165→      await sendCriticalAlert(
   166→        'Error Spike Detected',
   167→        `*${recentErrors.length} errors* in the last 5 minutes.\n\n` +
   168→        `*By page:*\n${pageBreakdown}\n\n` +
   169→        `*Action:* Check Railway logs for error details.`,
   170→        'critical'
   171→      );
   172→
   173→      // Log to detected_friction
   174→      const topPage = Object.entries(errorsByPage).sort((a, b) => b[1] - a[1])[0];
   175→      await logFriction('error_prone', topPage?.[0] || 'unknown', recentErrors.length, {
   176→        by_page: errorsByPage,
   177→        time_window: '5 minutes'
   178→      });
   179→    }
   180→  }
   181→}
   182→
   183→/**
   184→ * Check for high abandonment on a specific flow
   185→ * Called when flow_abandon events are logged
   186→ */
   187→async function checkAbandonmentSpike(page) {
   188→  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
   189→
   190→  // Get flow complete and abandon counts for this page in the last hour
   191→  const { data: flowEvents, error } = await db
   192→    .from('usage_events')
   193→    .select('event_type')
   194→    .eq('page', page)
   195→    .in('event_type', ['flow_complete', 'flow_abandon'])
   196→    .gte('created_at', oneHourAgo);
   197→
   198→  if (error || !flowEvents || flowEvents.length < 5) {
   199→    // Not enough data to calculate meaningful rate
   200→    return;
   201→  }
   202→
   203→  const completes = flowEvents.filter(e => e.event_type === 'flow_complete').length;
   204→  const abandons = flowEvents.filter(e => e.event_type === 'flow_abandon').length;
   205→  const total = completes + abandons;
   206→
   207→  if (total === 0) return;
   208→
   209→  const abandonRate = Math.round((abandons / total) * 100);
   210→
   211→  if (abandonRate >= THRESHOLDS.ABANDONMENT_RATE_SPIKE) {
   212→    const alertKey = `abandonment_${page}`;
   213→
   214→    if (shouldSendAlert(alertKey)) {
   215→      await sendCriticalAlert(
   216→        'High Abandonment Rate',
   217→        `*${abandonRate}% abandonment* on \`${page}\` in the last hour.\n` +
   218→        `• Completed: ${completes}\n` +
   219→        `• Abandoned: ${abandons}\n\n` +
   220→        `*Action:* Review ${page} flow for friction points.`,
   221→        'warning'
   222→      );
   223→
   224→      // Log to detected_friction
   225→      await logFriction('high_abandonment', page, abandonRate, {
   226→        completes,
   227→        abandons,
   228→        total,
   229→        time_window: '1 hour'
   230→      });
   231→    }
   232→  }
   233→}
   234→
   235→/**
   236→ * Check for API failures (Twilio, OpenAI, etc.)
   237→ * Called after API errors are logged
   238→ */
   239→async function checkApiFailures() {
   240→  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
   241→
   242→  const { data: recentFailures, error } = await db
   243→    .from('usage_events')
   244→    .select('id, event_name, page, context')
   245→    .eq('event_type', 'api_call')
   246→    .eq('context->>success', 'false')
   247→    .gte('created_at', fiveMinutesAgo);
   248→
   249→  if (error) {
   250→    console.error('[CriticalAlerts] Error checking API failures:', error);
   251→    return;
   252→  }
   253→
   254→  if (recentFailures && recentFailures.length >= THRESHOLDS.API_FAILURES_PER_5MIN) {
   255→    const alertKey = 'api_failure_spike';
   256→
   257→    if (shouldSendAlert(alertKey)) {
   258→      // Group by endpoint
   259→      const byEndpoint = {};
   260→      recentFailures.forEach(f => {
   261→        const endpoint = f.context?.endpoint || 'unknown';
   262→        byEndpoint[endpoint] = (byEndpoint[endpoint] || 0) + 1;
   263→      });
   264→
   265→      const endpointBreakdown = Object.entries(byEndpoint)
   266→        .sort((a, b) => b[1] - a[1])
   267→        .slice(0, 5)
   268→        .map(([endpoint, count]) => `• ${endpoint}: ${count}`)
   269→        .join('\n');
   270→
   271→      // Detect if it's a specific service down
   272→      const isTwilio = Object.keys(byEndpoint).some(e => e.includes('twilio') || e.includes('sms') || e.includes('call'));
   273→      const isOpenAI = Object.keys(byEndpoint).some(e => e.includes('openai') || e.includes('ai') || e.includes('gpt'));
   274→
   275→      let serviceName = 'API';
   276→      if (isTwilio) serviceName = 'Twilio';
   277→      if (isOpenAI) serviceName = 'OpenAI';
   278→
   279→      await sendCriticalAlert(
   280→        `${serviceName} Failures Detected`,
   281→        `*${recentFailures.length} API failures* in the last 5 minutes.\n\n` +
   282→        `*By endpoint:*\n${endpointBreakdown}\n\n` +
   283→        `*Action:* Check ${serviceName} status and Railway logs.`,
   284→        'critical'
   285→      );
   286→
   287→      // Log to detected_friction
   288→      await logFriction('api_failure', serviceName.toLowerCase(), recentFailures.length, {
   289→        by_endpoint: byEndpoint,
   290→        time_window: '5 minutes'
   291→      });
   292→    }
   293→  }
   294→}
   295→
   296→/**
   297→ * Check for SMS delivery failures
   298→ * Monitors delivery_status in communications table
   299→ */
   300→async function checkSmsDeliveryFailures() {
   301→  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
   302→
   303→  // Get SMS delivery stats from last hour
   304→  const { data: smsStats, error } = await db
   305→    .from('communications')
   306→    .select('delivery_status')
   307→    .eq('type', 'sms')
   308→    .eq('direction', 'outbound')
   309→    .gte('created_at', oneHourAgo);
   310→
   311→  if (error) {
   312→    console.error('[CriticalAlerts] Error checking SMS delivery:', error);
   313→    return;
   314→  }
   315→
   316→  if (!smsStats || smsStats.length < 5) {
   317→    // Not enough data
   318→    return;
   319→  }
   320→
   321→  const total = smsStats.length;
   322→  const failed = smsStats.filter(s => s.delivery_status === 'failed' || s.delivery_status === 'undelivered').length;
   323→  const failureRate = Math.round((failed / total) * 100);
   324→
   325→  if (failureRate >= THRESHOLDS.SMS_FAILURE_RATE) {
   326→    const alertKey = 'sms_delivery_failure';
   327→
   328→    if (shouldSendAlert(alertKey)) {
   329→      // Get recent failure reasons
   330→      const { data: failures } = await db
   331→        .from('communications')
   332→        .select('metadata')
   333→        .eq('type', 'sms')
   334→        .eq('direction', 'outbound')
   335→        .in('delivery_status', ['failed', 'undelivered'])
   336→        .gte('created_at', oneHourAgo)
   337→        .limit(10);
   338→
   339→      const reasons = failures?.map(f => f.metadata?.error_message || 'Unknown').filter(Boolean);
   340→      const uniqueReasons = [...new Set(reasons)].slice(0, 3);
   341→
   342→      await sendCriticalAlert(
   343→        'SMS Delivery Failures',
   344→        `*${failureRate}% SMS failure rate* in the last hour.\n` +
   345→        `• Total sent: ${total}\n` +
   346→        `• Failed: ${failed}\n\n` +
   347→        `*Common errors:*\n${uniqueReasons.map(r => `• ${r}`).join('\n') || '• No specific errors recorded'}\n\n` +
   348→        `*Action:* Check Twilio console and phone number validity.`,
   349→        failureRate >= 50 ? 'critical' : 'warning'
   350→      );
   351→
   352→      // Log to detected_friction
   353→      await logFriction('sms_delivery', 'twilio', failureRate, {
   354→        total,
   355→        failed,
   356→        failure_rate: failureRate,
   357→        common_errors: uniqueReasons,
   358→        time_window: '1 hour'
   359→      });
   360→    }
   361→  }
   362→}
   363→
   364→/**
   365→ * Check for slow API responses (performance degradation)
   366→ * Monitors api_call events with duration_ms > 5 seconds
   367→ */
   368→async function checkSlowApiResponses() {
   369→  const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
   370→
   371→  const { data: slowCalls, error } = await db
   372→    .from('usage_events')
   373→    .select('id, event_name, page, context, duration_ms')
   374→    .eq('event_type', 'api_call')
   375→    .gte('duration_ms', THRESHOLDS.SLOW_API_THRESHOLD_MS)
   376→    .gte('created_at', fiveMinutesAgo);
   377→
   378→  if (error) {
   379→    console.error('[CriticalAlerts] Error checking slow APIs:', error);
   380→    return;
   381→  }
   382→
   383→  if (slowCalls && slowCalls.length >= THRESHOLDS.SLOW_API_COUNT_PER_5MIN) {
   384→    const alertKey = 'slow_api_response';
   385→
   386→    if (shouldSendAlert(alertKey)) {
   387→      // Group by endpoint
   388→      const byEndpoint = {};
   389→      slowCalls.forEach(c => {
   390→        const endpoint = c.context?.endpoint || 'unknown';
   391→        if (!byEndpoint[endpoint]) {
   392→          byEndpoint[endpoint] = { count: 0, totalMs: 0 };
   393→        }
   394→        byEndpoint[endpoint].count++;
   395→        byEndpoint[endpoint].totalMs += c.duration_ms || 0;
   396→      });
   397→
   398→      const endpointBreakdown = Object.entries(byEndpoint)
   399→        .sort((a, b) => b[1].count - a[1].count)
   400→        .slice(0, 5)
   401→        .map(([endpoint, stats]) => {
   402→          const avgMs = Math.round(stats.totalMs / stats.count);
   403→          return `• ${endpoint}: ${stats.count}x (avg ${avgMs}ms)`;
   404→        })
   405→        .join('\n');
   406→
   407→      await sendCriticalAlert(
   408→        'Performance Degradation',
   409→        `*${slowCalls.length} slow API responses* (>${THRESHOLDS.SLOW_API_THRESHOLD_MS / 1000}s) in the last 5 minutes.\n\n` +
   410→        `*Slowest endpoints:*\n${endpointBreakdown}\n\n` +
   411→        `*Action:* Check Railway metrics, database load, and external API health.`,
   412→        'warning'
   413→      );
   414→
   415→      // Log to detected_friction
   416→      await logFriction('slow_api', 'performance', slowCalls.length, {
   417→        by_endpoint: byEndpoint,
   418→        threshold_ms: THRESHOLDS.SLOW_API_THRESHOLD_MS,
   419→        time_window: '5 minutes'
   420→      });
   421→    }
   422→  }
   423→}
   424→
   425→/**
   426→ * Log friction point to detected_friction table
   427→ */
   428→async function logFriction(frictionType, location, severityValue, evidence) {
   429→  try {
   430→    const today = new Date().toISOString().split('T')[0];
   431→
   432→    // Check if similar friction already detected today
   433→    const { data: existing } = await db
   434→      .from('detected_friction')
   435→      .select('id')
   436→      .eq('detection_date', today)
   437→      .eq('friction_type', frictionType)
   438→      .eq('location', location)
   439→      .single();
   440→
   441→    if (existing) {
   442→      // Update existing friction point
   443→      await db
   444→        .from('detected_friction')
   445→        .update({
   446→          severity_score: Math.max(severityValue, existing.severity_score || 0),
   447→          evidence: evidence,
   448→          occurrences_per_week: (existing.occurrences_per_week || 0) + 1
   449→        })
   450→        .eq('id', existing.id);
   451→    } else {
   452→      // Create new friction point
   453→      await db
   454→        .from('detected_friction')
   455→        .insert({
   456→          detection_date: today,
   457→          friction_type: frictionType,
   458→          location: location,
   459→          severity_score: severityValue,
   460→          evidence: evidence,
   461→          occurrences_per_week: 1,
   462→          status: 'detected'
   463→        });
   464→    }
   465→  } catch (error) {
   466→    console.error('[CriticalAlerts] Failed to log friction:', error.message);
   467→  }
   468→}
   469→
   470→/**
   471→ * Process a usage event and check for critical patterns
   472→ * Call this from the tracking endpoint
   473→ */
   474→async function processEventForAlerts(event) {
   475→  const { event_type, page, user_id, context, duration_ms } = event;
   476→
   477→  switch (event_type) {
   478→    case 'rage_click':
   479→      await checkRageClicks(page, user_id);
   480→      break;
   481→    case 'error':
   482→      await checkErrorSpike();
   483→      break;
   484→    case 'flow_abandon':
   485→      if (page) {
   486→        await checkAbandonmentSpike(page);
   487→      }
   488→      break;
   489→    case 'api_call':
   490→      // Check for API failures and slow responses
   491→      if (context?.success === false) {
   492→        await checkApiFailures();
   493→      }
   494→      if (duration_ms && duration_ms >= THRESHOLDS.SLOW_API_THRESHOLD_MS) {
   495→        await checkSlowApiResponses();
   496→      }
   497→      break;
   498→  }
   499→}
   500→
   501→/**
   502→ * Run a full check for all alert types
   503→ * Can be called periodically or manually
   504→ */
   505→async function runFullAlertCheck() {
   506→  console.log('[CriticalAlerts] Running full alert check...');
   507→
   508→  // Original checks
   509→  await checkErrorSpike();
   510→
   511→  // Check abandonment on all major pages
   512→  const majorPages = ['call_list', 'inbox', 'dashboard', 'lead_profile', 'call_prep'];
   513→  for (const page of majorPages) {
   514→    await checkAbandonmentSpike(page);
   515→  }
   516→
   517→  // New checks: API, SMS, Performance
   518→  await checkApiFailures();
   519→  await checkSmsDeliveryFailures();
   520→  await checkSlowApiResponses();
   521→
   522→  console.log('[CriticalAlerts] Full check complete');
   523→}
   524→
   525→module.exports = {
   526→  processEventForAlerts,
   527→  checkRageClicks,
   528→  checkErrorSpike,
   529→  checkAbandonmentSpike,
   530→  checkApiFailures,
   531→  checkSmsDeliveryFailures,
   532→  checkSlowApiResponses,
   533→  runFullAlertCheck,
   534→  sendCriticalAlert,
   535→  THRESHOLDS
   536→};
   537→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>