     1→/**
     2→ * Leaderboard Service - Real-time competitive rankings
     3→ * Part of Gamification Battleground (Priority #7)
     4→ */
     5→const { supabase } = require('../config/database');
     6→
     7→// Record types for Hall of Fame
     8→const RECORD_TYPES = {
     9→  MOST_CALLS_DAY: 'most_calls_day',
    10→  BIGGEST_DEAL: 'biggest_deal',
    11→  LONGEST_STREAK: 'longest_streak',
    12→  FASTEST_CLOSE: 'fastest_close',       // Minutes from first contact to conversion
    13→  MOST_WINS_WEEK: 'most_wins_week',
    14→};
    15→
    16→// Weekly badge types
    17→const WEEKLY_BADGES = {
    18→  CLOSER: { id: 'closer', emoji: '🦁', name: 'Closer', stat: 'conversions' },
    19→  DIALER: { id: 'dialer', emoji: '📞', name: 'Dialer', stat: 'calls' },
    20→  SPEED_DEMON: { id: 'speed_demon', emoji: '⚡', name: 'Speed Demon', stat: 'avg_response_time' },
    21→  SNIPER: { id: 'sniper', emoji: '🎯', name: 'Sniper', stat: 'conversion_rate' },
    22→  CONSISTENT: { id: 'consistent', emoji: '🛡', name: 'Consistent', stat: 'days_active' },
    23→  EARLY_BIRD: { id: 'early_bird', emoji: '🌅', name: 'Early Bird', stat: 'calls_before_9am' },
    24→  NIGHT_OWL: { id: 'night_owl', emoji: '🦉', name: 'Night Owl', stat: 'calls_after_5pm' },
    25→};
    26→
    27→// Streak tiers
    28→const STREAK_TIERS = {
    29→  3: { tier: 'bronze', emoji: '🔥', name: 'Bronze Flame' },
    30→  7: { tier: 'silver', emoji: '🔥🔥', name: 'Silver Blaze' },
    31→  14: { tier: 'gold', emoji: '🔥🔥🔥', name: 'Gold Inferno' },
    32→  30: { tier: 'diamond', emoji: '💎🔥', name: 'Diamond Legend' },
    33→};
    34→
    35→/**
    36→ * Get today's start in AEST timezone
    37→ * Returns a Date object representing midnight AEST as UTC
    38→ */
    39→function getTodayStartAEST() {
    40→  const AEST_OFFSET_HOURS = 10;
    41→  const AEST_OFFSET_MS = AEST_OFFSET_HOURS * 60 * 60 * 1000;
    42→  const now = new Date();
    43→
    44→  // Convert current UTC time to AEST by adding offset
    45→  const aestTime = new Date(now.getTime() + AEST_OFFSET_MS);
    46→
    47→  // Get midnight AEST (using UTC methods on the shifted time)
    48→  const midnightAESTAsUTC = new Date(Date.UTC(
    49→    aestTime.getUTCFullYear(),
    50→    aestTime.getUTCMonth(),
    51→    aestTime.getUTCDate(),
    52→    0, 0, 0, 0
    53→  ));
    54→
    55→  // Convert back to actual UTC by subtracting offset
    56→  return new Date(midnightAESTAsUTC.getTime() - AEST_OFFSET_MS);
    57→}
    58→
    59→/**
    60→ * Get week start in AEST (Monday at midnight AEST)
    61→ */
    62→function getWeekStartAEST() {
    63→  const AEST_OFFSET_HOURS = 10;
    64→  const AEST_OFFSET_MS = AEST_OFFSET_HOURS * 60 * 60 * 1000;
    65→  const now = new Date();
    66→
    67→  // Convert to AEST
    68→  const aestTime = new Date(now.getTime() + AEST_OFFSET_MS);
    69→
    70→  // Get day of week in AEST (0 = Sunday)
    71→  const aestDay = aestTime.getUTCDay();
    72→  // Calculate days to subtract to get to Monday (1)
    73→  const daysToMonday = aestDay === 0 ? 6 : aestDay - 1;
    74→
    75→  const aestWeekStart = new Date(Date.UTC(
    76→    aestTime.getUTCFullYear(),
    77→    aestTime.getUTCMonth(),
    78→    aestTime.getUTCDate() - daysToMonday,
    79→    0, 0, 0, 0
    80→  ));
    81→
    82→  // Convert back to UTC
    83→  return new Date(aestWeekStart.getTime() - AEST_OFFSET_MS);
    84→}
    85→
    86→/**
    87→ * Get month start in AEST
    88→ */
    89→function getMonthStartAEST() {
    90→  const AEST_OFFSET_HOURS = 10;
    91→  const AEST_OFFSET_MS = AEST_OFFSET_HOURS * 60 * 60 * 1000;
    92→  const now = new Date();
    93→
    94→  // Convert to AEST
    95→  const aestTime = new Date(now.getTime() + AEST_OFFSET_MS);
    96→
    97→  const aestMonthStart = new Date(Date.UTC(
    98→    aestTime.getUTCFullYear(),
    99→    aestTime.getUTCMonth(),
   100→    1, 0, 0, 0, 0
   101→  ));
   102→
   103→  // Convert back to UTC
   104→  return new Date(aestMonthStart.getTime() - AEST_OFFSET_MS);
   105→}
   106→
   107→/**
   108→ * Get leaderboard for a specific period
   109→ */
   110→async function getLeaderboard(period = 'today', limit = 10) {
   111→  try {
   112→    const now = new Date();
   113→    let startDate;
   114→
   115→    // Use AEST timezone for Australian users
   116→    switch (period) {
   117→      case 'today':
   118→        startDate = getTodayStartAEST().toISOString();
   119→        break;
   120→      case 'week':
   121→        startDate = getWeekStartAEST().toISOString();
   122→        break;
   123→      case 'month':
   124→        startDate = getMonthStartAEST().toISOString();
   125→        break;
   126→      default:
   127→        startDate = null; // all time
   128→    }
   129→
   130→    // Get XP earned in period from xp_history
   131→    let xpQuery = supabase
   132→      .from('xp_history')
   133→      .select('user_id, amount');
   134→
   135→    if (startDate) {
   136→      xpQuery = xpQuery.gte('created_at', startDate);
   137→    }
   138→
   139→    const { data: xpData, error: xpError } = await xpQuery;
   140→    if (xpError) console.error('XP query error:', xpError);
   141→
   142→    // Aggregate by user
   143→    const userXP = {};
   144→    (xpData || []).forEach(row => {
   145→      userXP[row.user_id] = (userXP[row.user_id] || 0) + row.amount;
   146→    });
   147→
   148→    // Get user profiles
   149→    const { data: users, error: usersError } = await supabase
   150→      .from('user_xp')
   151→      .select('user_id, display_name, level, current_streak, current_badges, status, total_xp');
   152→
   153→    if (usersError) console.error('Users query error:', usersError);
   154→
   155→    // Get previous positions for comparison
   156→    const yesterday = new Date();
   157→    yesterday.setDate(yesterday.getDate() - 1);
   158→    const { data: prevSnapshot } = await supabase
   159→      .from('leaderboard_snapshots')
   160→      .select('user_id, position')
   161→      .eq('period', period)
   162→      .eq('snapshot_date', yesterday.toISOString().split('T')[0]);
   163→
   164→    const prevPositions = {};
   165→    (prevSnapshot || []).forEach(row => {
   166→      prevPositions[row.user_id] = row.position;
   167→    });
   168→
   169→    // Get call/conversion counts for period
   170→    let callQuery = supabase
   171→      .from('call_log')
   172→      .select('user_id, outcome');
   173→
   174→    if (startDate) {
   175→      callQuery = callQuery.gte('created_at', startDate);
   176→    }
   177→
   178→    const { data: callStats } = await callQuery;
   179→
   180→    const userCalls = {};
   181→    const userConversions = {};
   182→    (callStats || []).forEach(row => {
   183→      if (!row.user_id) return;
   184→      userCalls[row.user_id] = (userCalls[row.user_id] || 0) + 1;
   185→      if (row.outcome === 'converted' || row.outcome === 'won') {
   186→        userConversions[row.user_id] = (userConversions[row.user_id] || 0) + 1;
   187→      }
   188→    });
   189→
   190→    // Build leaderboard
   191→    const leaderboard = (users || [])
   192→      .map(user => ({
   193→        userId: user.user_id,
   194→        name: user.display_name || 'Anonymous',
   195→        xp: period === 'all' ? (user.total_xp || 0) : (userXP[user.user_id] || 0),
   196→        level: user.level || 1,
   197→        streak: user.current_streak || 0,
   198→        badges: user.current_badges || [],
   199→        status: user.status || 'grinding',
   200→        calls: userCalls[user.user_id] || 0,
   201→        conversions: userConversions[user.user_id] || 0,
   202→      }))
   203→      .sort((a, b) => b.xp - a.xp)
   204→      .slice(0, limit)
   205→      .map((user, index) => {
   206→        const prevPos = prevPositions[user.userId];
   207→        let positionChange = 0;
   208→        if (prevPos !== undefined) {
   209→          positionChange = prevPos - (index + 1); // Positive = moved up
   210→        }
   211→        return {
   212→          ...user,
   213→          position: index + 1,
   214→          positionChange,
   215→          isLeader: index === 0,
   216→        };
   217→      });
   218→
   219→    return { leaderboard, period };
   220→  } catch (error) {
   221→    console.error('getLeaderboard error:', error);
   222→    return { leaderboard: [], period, error: error.message };
   223→  }
   224→}
   225→
   226→/**
   227→ * Check for overtake and create alert
   228→ */
   229→async function checkOvertake(userId, userName, newPosition, period) {
   230→  try {
   231→    const { data: prev } = await supabase
   232→      .from('leaderboard_snapshots')
   233→      .select('position')
   234→      .eq('user_id', userId)
   235→      .eq('period', period)
   236→      .order('snapshot_date', { ascending: false })
   237→      .limit(1)
   238→      .single();
   239→
   240→    if (prev && newPosition < prev.position) {
   241→      // User moved up - find who they overtook
   242→      const { leaderboard } = await getLeaderboard(period, 10);
   243→      const overtaken = leaderboard.find(u => u.position === newPosition + 1);
   244→
   245→      if (overtaken) {
   246→        await createPulseEvent({
   247→          event_type: 'overtake',
   248→          user_id: userId,
   249→          user_name: userName,
   250→          title: `${userName} just overtook ${overtaken.name}!`,
   251→          subtitle: `Now #${newPosition} on the ${period} leaderboard`,
   252→          emoji: '🚀',
   253→          link_type: 'leaderboard',
   254→        });
   255→
   256→        return { overtook: overtaken.name, newPosition };
   257→      }
   258→    }
   259→    return null;
   260→  } catch (error) {
   261→    console.error('checkOvertake error:', error);
   262→    return null;
   263→  }
   264→}
   265→
   266→/**
   267→ * Check and update Hall of Fame record
   268→ */
   269→async function checkRecord(recordType, userId, userName, value, details = {}) {
   270→  try {
   271→    const { data: current } = await supabase
   272→      .from('hall_of_fame')
   273→      .select('*')
   274→      .eq('record_type', recordType)
   275→      .single();
   276→
   277→    if (!current || value > current.value) {
   278→      // NEW RECORD!
   279→      const { error } = await supabase
   280→        .from('hall_of_fame')
   281→        .upsert({
   282→          record_type: recordType,
   283→          user_id: userId,
   284→          user_name: userName,
   285→          value,
   286→          details,
   287→          previous_holder_id: current?.user_id,
   288→          previous_value: current?.value,
   289→          achieved_at: new Date().toISOString(),
   290→        });
   291→
   292→      if (error) {
   293→        console.error('Hall of Fame upsert error:', error);
   294→        return { newRecord: false, error: error.message };
   295→      }
   296→
   297→      // Create pulse event
   298→      await createPulseEvent({
   299→        event_type: 'record_broken',
   300→        user_id: userId,
   301→        user_name: userName,
   302→        title: `NEW RECORD! ${userName} broke the ${formatRecordName(recordType)} record!`,
   303→        subtitle: current ? `Previous: ${current.user_name} (${current.value})` : 'First ever!',
   304→        emoji: '🏆',
   305→      });
   306→
   307→      // Notify Slack
   308→      await notifySlackRecord(recordType, userName, value, current);
   309→
   310→      return { newRecord: true, previousHolder: current?.user_name };
   311→    }
   312→
   313→    return { newRecord: false };
   314→  } catch (error) {
   315→    console.error('checkRecord error:', error);
   316→    return { newRecord: false, error: error.message };
   317→  }
   318→}
   319→
   320→/**
   321→ * Get Hall of Fame
   322→ */
   323→async function getHallOfFame() {
   324→  try {
   325→    const { data, error } = await supabase
   326→      .from('hall_of_fame')
   327→      .select('*')
   328→      .order('achieved_at', { ascending: false });
   329→
   330→    if (error) {
   331→      console.error('getHallOfFame error:', error);
   332→      return [];
   333→    }
   334→
   335→    return data || [];
   336→  } catch (error) {
   337→    console.error('getHallOfFame error:', error);
   338→    return [];
   339→  }
   340→}
   341→
   342→/**
   343→ * Create team pulse event
   344→ */
   345→async function createPulseEvent({ event_type, user_id, user_name, title, subtitle, emoji, link_type, link_id }) {
   346→  try {
   347→    const { error } = await supabase
   348→      .from('team_pulse')
   349→      .insert({
   350→        event_type,
   351→        user_id,
   352→        user_name,
   353→        title,
   354→        subtitle,
   355→        emoji,
   356→        link_type,
   357→        link_id,
   358→      });
   359→
   360→    if (error) {
   361→      console.error('createPulseEvent error:', error);
   362→    }
   363→  } catch (error) {
   364→    console.error('createPulseEvent error:', error);
   365→  }
   366→}
   367→
   368→/**
   369→ * Get team pulse feed
   370→ */
   371→async function getTeamPulse(limit = 20) {
   372→  try {
   373→    const { data, error } = await supabase
   374→      .from('team_pulse')
   375→      .select('*')
   376→      .order('created_at', { ascending: false })
   377→      .limit(limit);
   378→
   379→    if (error) {
   380→      console.error('getTeamPulse error:', error);
   381→      return [];
   382→    }
   383→
   384→    return data || [];
   385→  } catch (error) {
   386→    console.error('getTeamPulse error:', error);
   387→    return [];
   388→  }
   389→}
   390→
   391→/**
   392→ * Update streak tier
   393→ */
   394→async function updateStreakTier(userId, streakDays) {
   395→  try {
   396→    let tier = 'none';
   397→    const thresholds = Object.keys(STREAK_TIERS).map(Number).sort((a, b) => b - a);
   398→
   399→    for (const threshold of thresholds) {
   400→      if (streakDays >= threshold) {
   401→        tier = STREAK_TIERS[threshold].tier;
   402→        break;
   403→      }
   404→    }
   405→
   406→    const { error } = await supabase
   407→      .from('user_xp')
   408→      .update({ streak_tier: tier })
   409→      .eq('user_id', userId);
   410→
   411→    if (error) {
   412→      console.error('updateStreakTier error:', error);
   413→    }
   414→
   415→    return tier;
   416→  } catch (error) {
   417→    console.error('updateStreakTier error:', error);
   418→    return 'none';
   419→  }
   420→}
   421→
   422→/**
   423→ * Save leaderboard snapshot (for tracking position changes)
   424→ */
   425→async function saveLeaderboardSnapshot(period = 'today') {
   426→  try {
   427→    const { leaderboard } = await getLeaderboard(period, 50);
   428→    const today = new Date().toISOString().split('T')[0];
   429→
   430→    const snapshots = leaderboard.map(user => ({
   431→      user_id: user.userId,
   432→      period,
   433→      snapshot_date: today,
   434→      position: user.position,
   435→      calls: user.calls,
   436→      conversions: user.conversions,
   437→      xp: user.xp,
   438→      streak: user.streak,
   439→    }));
   440→
   441→    if (snapshots.length > 0) {
   442→      const { error } = await supabase
   443→        .from('leaderboard_snapshots')
   444→        .upsert(snapshots, { onConflict: 'user_id,period,snapshot_date' });
   445→
   446→      if (error) {
   447→        console.error('saveLeaderboardSnapshot error:', error);
   448→      }
   449→    }
   450→
   451→    return { saved: snapshots.length };
   452→  } catch (error) {
   453→    console.error('saveLeaderboardSnapshot error:', error);
   454→    return { saved: 0, error: error.message };
   455→  }
   456→}
   457→
   458→/**
   459→ * Notify Slack of record
   460→ */
   461→async function notifySlackRecord(recordType, userName, value, previous) {
   462→  try {
   463→    const slackService = require('./slack');
   464→    const recordName = formatRecordName(recordType);
   465→
   466→    let message = `*HALL OF FAME RECORD BROKEN!*\n\n`;
   467→    message += `*${userName}* just set a new record for *${recordName}*!\n`;
   468→    message += `New record: *${value}*\n`;
   469→    if (previous) {
   470→      message += `Previous holder: ${previous.user_name} (${previous.value})`;
   471→    }
   472→
   473→    await slackService.sendNotification(message, '#sales-wins');
   474→  } catch (error) {
   475→    console.error('notifySlackRecord error:', error);
   476→  }
   477→}
   478→
   479→function formatRecordName(recordType) {
   480→  const names = {
   481→    most_calls_day: 'Most Calls in a Day',
   482→    biggest_deal: 'Biggest Deal',
   483→    longest_streak: 'Longest Streak',
   484→    fastest_close: 'Fastest Close',
   485→    most_wins_week: 'Most Wins in a Week',
   486→  };
   487→  return names[recordType] || recordType;
   488→}
   489→
   490→/**
   491→ * Get today's call count for a user (for record checking)
   492→ * Uses communications table with type='call_outbound'
   493→ */
   494→async function getTodaysCallCount(userId) {
   495→  try {
   496→    // Use AEST timezone for "today"
   497→    const todayStart = getTodayStartAEST();
   498→
   499→    const { data, error } = await supabase
   500→      .from('communications')
   501→      .select('id')
   502→      .eq('type', 'call_outbound')
   503→      .gte('created_at', todayStart.toISOString());
   504→
   505→    if (error) {
   506→      console.error('getTodaysCallCount error:', error);
   507→      return 0;
   508→    }
   509→
   510→    return data?.length || 0;
   511→  } catch (error) {
   512→    console.error('getTodaysCallCount error:', error);
   513→    return 0;
   514→  }
   515→}
   516→
   517→module.exports = {
   518→  RECORD_TYPES,
   519→  WEEKLY_BADGES,
   520→  STREAK_TIERS,
   521→  getLeaderboard,
   522→  checkOvertake,
   523→  checkRecord,
   524→  getHallOfFame,
   525→  createPulseEvent,
   526→  getTeamPulse,
   527→  updateStreakTier,
   528→  saveLeaderboardSnapshot,
   529→  getTodaysCallCount,
   530→  formatRecordName,
   531→};
   532→

<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→ * Deepgram Service for Live Transcription
     3→ * Uses Deepgram's WebSocket API for real-time speech-to-text
     4→ */
     5→
     6→const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY;
     7→
     8→/**
     9→ * Check if Deepgram is configured
    10→ */
    11→function isConfigured() {
    12→  return !!DEEPGRAM_API_KEY;
    13→}
    14→
    15→/**
    16→ * Get Deepgram configuration for client
    17→ * Returns URL and API key for real-time WebSocket transcription
    18→ */
    19→function getClientConfig() {
    20→  if (!DEEPGRAM_API_KEY) {
    21→    return {
    22→      configured: false,
    23→      error: 'Deepgram not configured - add DEEPGRAM_API_KEY to environment'
    24→    };
    25→  }
    26→
    27→  // Return WebSocket URL with parameters
    28→  // Note: Don't specify sample_rate for container formats (WebM) - auto-detected
    29→  // Raw PCM audio from Web Audio API (browser default is 48kHz)
    30→  const params = new URLSearchParams({
    31→    model: 'nova-2',
    32→    language: 'en-AU',
    33→    encoding: 'linear16',
    34→    sample_rate: '48000',
    35→    channels: '1',
    36→    smart_format: 'true',
    37→    interim_results: 'true',
    38→    vad_events: 'true',
    39→    punctuate: 'true',
    40→    endpointing: '300',
    41→  });
    42→
    43→  return {
    44→    configured: true,
    45→    // Client expects 'url' and 'apiKey' for WebSocket connection
    46→    url: `wss://api.deepgram.com/v1/listen?${params.toString()}`,
    47→    apiKey: DEEPGRAM_API_KEY,
    48→  };
    49→}
    50→
    51→/**
    52→ * Get API key for server-side use only
    53→ * NEVER expose this to the client
    54→ */
    55→function getApiKey() {
    56→  return DEEPGRAM_API_KEY;
    57→}
    58→
    59→/**
    60→ * Get full WebSocket URL with auth for server-side proxying
    61→ * @returns {string|null}
    62→ */
    63→function getAuthenticatedWSUrl() {
    64→  if (!DEEPGRAM_API_KEY) {
    65→    return null;
    66→  }
    67→
    68→  const params = new URLSearchParams({
    69→    model: 'nova-2',
    70→    language: 'en-AU',
    71→    smart_format: 'true',
    72→    interim_results: 'true',
    73→    utterance_end_ms: '1000',
    74→    vad_events: 'true',
    75→    punctuate: 'true',
    76→  });
    77→
    78→  return `wss://api.deepgram.com/v1/listen?${params.toString()}`;
    79→}
    80→
    81→module.exports = {
    82→  isConfigured,
    83→  getClientConfig,
    84→  getApiKey,
    85→  getAuthenticatedWSUrl,
    86→};
    87→

<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→ * Lead Scoring Service
     3→ * Calculates and updates lead scores based on activity
     4→ */
     5→
     6→const { supabase } = require('../config/database');
     7→
     8→// Scoring rules
     9→const SCORE_RULES = {
    10→  // Positive signals
    11→  sms_reply_positive: 40,
    12→  sms_reply_question: 20,
    13→  call_interested: 30,
    14→  call_connected: 10,
    15→  platform_job_posted: 50,
    16→  platform_worker_hired: 30,
    17→  platform_login_recent: 20,
    18→
    19→  // Buying signals (stackable)
    20→  buying_signal_high: 30,    // "Sign me up", "When can you start?"
    21→  buying_signal_medium: 15,  // "That's reasonable", "Have a job starting"
    22→  buying_signal_low: 5,      // "Tell me more", "What does it cost?"
    23→
    24→  // Negative signals
    25→  sms_reply_negative: -100,
    26→  call_not_interested: -50,
    27→  call_wrong_number: -100,
    28→
    29→  // Decay
    30→  no_response_14_days: -20,
    31→  days_since_contact_penalty: -2, // per day over 7 days
    32→};
    33→
    34→/**
    35→ * Calculate score for a lead
    36→ * @param {string} leadId - Lead UUID
    37→ * @returns {Promise<{score: number, breakdown: object}>}
    38→ */
    39→async function calculateLeadScore(leadId) {
    40→  if (!supabase) {
    41→    return { score: 0, breakdown: {} };
    42→  }
    43→
    44→  const breakdown = {};
    45→  let score = 0;
    46→
    47→  // Get lead
    48→  const { data: lead } = await supabase
    49→    .from('leads')
    50→    .select('*')
    51→    .eq('id', leadId)
    52→    .single();
    53→
    54→  if (!lead) {
    55→    return { score: 0, breakdown: { error: 'Lead not found' } };
    56→  }
    57→
    58→  // Get communications
    59→  const { data: comms } = await supabase
    60→    .from('communications')
    61→    .select('*')
    62→    .eq('lead_id', leadId)
    63→    .order('created_at', { ascending: false });
    64→
    65→  // Get platform activity
    66→  const { data: activity } = await supabase
    67→    .from('platform_activity')
    68→    .select('*')
    69→    .eq('lead_id', leadId)
    70→    .order('activity_at', { ascending: false });
    71→
    72→  // Score SMS replies and buying signals
    73→  const inboundSms = (comms || []).filter(c => c.type === 'sms_inbound');
    74→  for (const sms of inboundSms) {
    75→    const intent = sms.metadata?.intent;
    76→    if (intent === 'positive') {
    77→      score += SCORE_RULES.sms_reply_positive;
    78→      breakdown.sms_reply_positive = (breakdown.sms_reply_positive || 0) + SCORE_RULES.sms_reply_positive;
    79→    } else if (intent === 'question') {
    80→      score += SCORE_RULES.sms_reply_question;
    81→      breakdown.sms_reply_question = (breakdown.sms_reply_question || 0) + SCORE_RULES.sms_reply_question;
    82→    } else if (intent === 'negative') {
    83→      score += SCORE_RULES.sms_reply_negative;
    84→      breakdown.sms_reply_negative = SCORE_RULES.sms_reply_negative;
    85→      // Negative reply caps the score
    86→      break;
    87→    }
    88→
    89→    // Add buying signal bonus (on top of intent scoring)
    90→    const buyingSignal = sms.metadata?.buyingSignal;
    91→    if (buyingSignal?.level === 'high') {
    92→      score += SCORE_RULES.buying_signal_high;
    93→      breakdown.buying_signal_high = (breakdown.buying_signal_high || 0) + SCORE_RULES.buying_signal_high;
    94→    } else if (buyingSignal?.level === 'medium') {
    95→      score += SCORE_RULES.buying_signal_medium;
    96→      breakdown.buying_signal_medium = (breakdown.buying_signal_medium || 0) + SCORE_RULES.buying_signal_medium;
    97→    } else if (buyingSignal?.level === 'low') {
    98→      score += SCORE_RULES.buying_signal_low;
    99→      breakdown.buying_signal_low = (breakdown.buying_signal_low || 0) + SCORE_RULES.buying_signal_low;
   100→    }
   101→  }
   102→
   103→  // Also check buying signals stored in lead metadata (from calls, etc.)
   104→  const storedSignals = lead.metadata?.buying_signals || [];
   105→  const recentSignals = storedSignals.filter(s => {
   106→    const signalDate = new Date(s.detected_at);
   107→    const daysSince = (Date.now() - signalDate.getTime()) / (1000 * 60 * 60 * 24);
   108→    return daysSince <= 30; // Only count signals from last 30 days
   109→  });
   110→  for (const signal of recentSignals) {
   111→    if (signal.type === 'high' && !breakdown.buying_signal_high) {
   112→      score += SCORE_RULES.buying_signal_high;
   113→      breakdown.buying_signal_high = (breakdown.buying_signal_high || 0) + SCORE_RULES.buying_signal_high;
   114→    } else if (signal.type === 'medium' && !breakdown.buying_signal_medium) {
   115→      score += SCORE_RULES.buying_signal_medium;
   116→      breakdown.buying_signal_medium = (breakdown.buying_signal_medium || 0) + SCORE_RULES.buying_signal_medium;
   117→    } else if (signal.type === 'low' && !breakdown.buying_signal_low) {
   118→      score += SCORE_RULES.buying_signal_low;
   119→      breakdown.buying_signal_low = (breakdown.buying_signal_low || 0) + SCORE_RULES.buying_signal_low;
   120→    }
   121→  }
   122→
   123→  // Score call outcomes
   124→  const calls = (comms || []).filter(c => c.type === 'call_outbound');
   125→  for (const call of calls) {
   126→    const outcome = call.metadata?.outcome;
   127→    if (outcome === 'interested') {
   128→      score += SCORE_RULES.call_interested;
   129→      breakdown.call_interested = (breakdown.call_interested || 0) + SCORE_RULES.call_interested;
   130→    } else if (outcome === 'connected') {
   131→      score += SCORE_RULES.call_connected;
   132→      breakdown.call_connected = (breakdown.call_connected || 0) + SCORE_RULES.call_connected;
   133→    } else if (outcome === 'not_interested') {
   134→      score += SCORE_RULES.call_not_interested;
   135→      breakdown.call_not_interested = SCORE_RULES.call_not_interested;
   136→    } else if (outcome === 'wrong_number') {
   137→      score += SCORE_RULES.call_wrong_number;
   138→      breakdown.call_wrong_number = SCORE_RULES.call_wrong_number;
   139→    }
   140→  }
   141→
   142→  // Score platform activity
   143→  for (const act of (activity || [])) {
   144→    if (act.activity_type === 'job_posted') {
   145→      score += SCORE_RULES.platform_job_posted;
   146→      breakdown.platform_job_posted = (breakdown.platform_job_posted || 0) + SCORE_RULES.platform_job_posted;
   147→    } else if (act.activity_type === 'worker_hired') {
   148→      score += SCORE_RULES.platform_worker_hired;
   149→      breakdown.platform_worker_hired = (breakdown.platform_worker_hired || 0) + SCORE_RULES.platform_worker_hired;
   150→    } else if (act.activity_type === 'login') {
   151→      const actDate = new Date(act.activity_at);
   152→      const daysSince = (Date.now() - actDate.getTime()) / (1000 * 60 * 60 * 24);
   153→      if (daysSince <= 7) {
   154→        score += SCORE_RULES.platform_login_recent;
   155→        breakdown.platform_login_recent = SCORE_RULES.platform_login_recent;
   156→      }
   157→    }
   158→  }
   159→
   160→  // Apply decay for no recent contact
   161→  if (lead.last_contact_at) {
   162→    const lastContact = new Date(lead.last_contact_at);
   163→    const daysSince = (Date.now() - lastContact.getTime()) / (1000 * 60 * 60 * 24);
   164→
   165→    if (daysSince > 14) {
   166→      score += SCORE_RULES.no_response_14_days;
   167→      breakdown.no_response_14_days = SCORE_RULES.no_response_14_days;
   168→    }
   169→
   170→    if (daysSince > 7) {
   171→      const penalty = Math.floor(daysSince - 7) * SCORE_RULES.days_since_contact_penalty;
   172→      score += penalty;
   173→      breakdown.days_since_contact_penalty = penalty;
   174→    }
   175→  }
   176→
   177→  // Ensure score is within bounds
   178→  score = Math.max(-100, Math.min(100, score));
   179→
   180→  return { score, breakdown };
   181→}
   182→
   183→/**
   184→ * Update a lead's score
   185→ * @param {string} leadId - Lead UUID
   186→ * @returns {Promise<{success: boolean, score?: number, error?: string}>}
   187→ */
   188→async function updateLeadScore(leadId) {
   189→  if (!supabase) {
   190→    return { success: false, error: 'Database not configured' };
   191→  }
   192→
   193→  const { score, breakdown } = await calculateLeadScore(leadId);
   194→
   195→  // Get current lead metadata to merge with score_breakdown
   196→  const { data: lead, error: fetchError } = await supabase
   197→    .from('leads')
   198→    .select('metadata')
   199→    .eq('id', leadId)
   200→    .single();
   201→
   202→  if (fetchError) {
   203→    return { success: false, error: fetchError.message };
   204→  }
   205→
   206→  // Merge score_breakdown into existing metadata
   207→  const currentMetadata = lead?.metadata || {};
   208→  const updatedMetadata = {
   209→    ...currentMetadata,
   210→    score_breakdown: breakdown
   211→  };
   212→
   213→  const { error } = await supabase
   214→    .from('leads')
   215→    .update({
   216→      score,
   217→      metadata: updatedMetadata
   218→    })
   219→    .eq('id', leadId);
   220→
   221→  if (error) {
   222→    return { success: false, error: error.message };
   223→  }
   224→
   225→  return { success: true, score, breakdown };
   226→}
   227→
   228→/**
   229→ * Recalculate scores for all active leads
   230→ * @returns {Promise<{updated: number, errors: number}>}
   231→ */
   232→async function recalculateAllScores() {
   233→  if (!supabase) {
   234→    return { updated: 0, errors: 0 };
   235→  }
   236→
   237→  const { data: leads } = await supabase
   238→    .from('leads')
   239→    .select('id')
   240→    .is('deleted_at', null)
   241→    .not('status', 'in', '("converted","opted_out","archived")');
   242→
   243→  let updated = 0;
   244→  let errors = 0;
   245→
   246→  for (const lead of (leads || [])) {
   247→    const result = await updateLeadScore(lead.id);
   248→    if (result.success) {
   249→      updated++;
   250→    } else {
   251→      errors++;
   252→    }
   253→  }
   254→
   255→  console.log(`Score recalculation: ${updated} updated, ${errors} errors`);
   256→  return { updated, errors };
   257→}
   258→
   259→/**
   260→ * Get score tier label
   261→ * @param {number} score - Lead score
   262→ * @returns {string} Tier label
   263→ */
   264→function getScoreTier(score) {
   265→  if (score >= 70) return 'hot';
   266→  if (score >= 50) return 'warm';
   267→  if (score >= 20) return 'cool';
   268→  if (score >= 0) return 'cold';
   269→  return 'dead';
   270→}
   271→
   272→module.exports = {
   273→  calculateLeadScore,
   274→  updateLeadScore,
   275→  recalculateAllScores,
   276→  getScoreTier,
   277→  SCORE_RULES
   278→};
   279→

<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→ * Push Notifications Service
     3→ * Sends browser push notifications via Web Push protocol
     4→ */
     5→
     6→const webpush = require('web-push');
     7→const { supabase, supabaseAdmin } = require('../config/database');
     8→
     9→const db = supabaseAdmin || supabase;
    10→
    11→// Configure VAPID (Voluntary Application Server Identification)
    12→if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
    13→  webpush.setVapidDetails(
    14→    'mailto:support@rateright.com.au',
    15→    process.env.VAPID_PUBLIC_KEY,
    16→    process.env.VAPID_PRIVATE_KEY
    17→  );
    18→  console.log('[PushNotifications] VAPID configured');
    19→} else {
    20→  console.warn('[PushNotifications] VAPID keys not configured - push notifications disabled');
    21→}
    22→
    23→/**
    24→ * Send push notification to a specific user
    25→ * @param {string} userId - User ID to send notification to
    26→ * @param {object} notification - Notification payload
    27→ * @returns {Promise<{sent: number, reason?: string}>}
    28→ */
    29→async function sendPushNotification(userId, notification) {
    30→  if (!process.env.VAPID_PUBLIC_KEY) {
    31→    console.log('[Push] VAPID not configured, skipping');
    32→    return { sent: 0, reason: 'vapid_not_configured' };
    33→  }
    34→
    35→  try {
    36→    // Check user preferences
    37→    const { data: prefs } = await db
    38→      .from('notification_preferences')
    39→      .select('*')
    40→      .eq('user_id', userId)
    41→      .single();
    42→
    43→    // Check quiet hours (AEST)
    44→    if (prefs && isQuietHours(prefs.quiet_hours_start, prefs.quiet_hours_end)) {
    45→      console.log(`[Push] Skipping - quiet hours for user ${userId}`);
    46→      return { sent: 0, reason: 'quiet_hours' };
    47→    }
    48→
    49→    // Check preference for this notification type
    50→    const typeKey = notification.type; // e.g., 'high_intent_sms'
    51→    if (prefs && typeKey && prefs[typeKey] === false) {
    52→      console.log(`[Push] Skipping - ${typeKey} disabled for user ${userId}`);
    53→      return { sent: 0, reason: 'preference_disabled' };
    54→    }
    55→
    56→    // Get user's push subscriptions
    57→    const { data: subscriptions, error: subError } = await db
    58→      .from('push_subscriptions')
    59→      .select('*')
    60→      .eq('user_id', userId);
    61→
    62→    if (subError) {
    63→      console.error('[Push] Error fetching subscriptions:', subError);
    64→      return { sent: 0, reason: 'db_error' };
    65→    }
    66→
    67→    if (!subscriptions?.length) {
    68→      console.log(`[Push] No subscriptions for user ${userId}`);
    69→      return { sent: 0, reason: 'no_subscriptions' };
    70→    }
    71→
    72→    let sent = 0;
    73→    for (const sub of subscriptions) {
    74→      try {
    75→        await webpush.sendNotification(
    76→          {
    77→            endpoint: sub.endpoint,
    78→            keys: sub.keys
    79→          },
    80→          JSON.stringify(notification)
    81→        );
    82→        sent++;
    83→
    84→        // Update last_used_at
    85→        await db
    86→          .from('push_subscriptions')
    87→          .update({ last_used_at: new Date().toISOString() })
    88→          .eq('id', sub.id);
    89→
    90→        console.log(`[Push] Sent to user ${userId} via subscription ${sub.id}`);
    91→      } catch (err) {
    92→        if (err.statusCode === 410 || err.statusCode === 404) {
    93→          // Subscription expired, remove it
    94→          await db.from('push_subscriptions').delete().eq('id', sub.id);
    95→          console.log(`[Push] Removed expired subscription ${sub.id}`);
    96→        } else {
    97→          console.error(`[Push] Failed to send to ${sub.id}:`, err.message);
    98→        }
    99→      }
   100→    }
   101→
   102→    return { sent };
   103→  } catch (error) {
   104→    console.error('[Push] Error:', error);
   105→    return { sent: 0, reason: error.message };
   106→  }
   107→}
   108→
   109→/**
   110→ * Check if current time is within quiet hours (AEST)
   111→ * @param {string} start - Start time (e.g., '22:00')
   112→ * @param {string} end - End time (e.g., '07:00')
   113→ * @returns {boolean}
   114→ */
   115→function isQuietHours(start, end) {
   116→  if (!start || !end) return false;
   117→
   118→  const now = new Date();
   119→  const aestOffset = 10 * 60; // AEST = UTC+10 in minutes
   120→  const nowMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
   121→  const aestMinutes = (nowMinutes + aestOffset) % (24 * 60);
   122→
   123→  const [startH, startM] = start.split(':').map(Number);
   124→  const [endH, endM] = end.split(':').map(Number);
   125→
   126→  const startMinutes = startH * 60 + (startM || 0);
   127→  const endMinutes = endH * 60 + (endM || 0);
   128→
   129→  // Handle overnight quiet hours (e.g., 22:00 - 07:00)
   130→  if (startMinutes > endMinutes) {
   131→    return aestMinutes >= startMinutes || aestMinutes < endMinutes;
   132→  }
   133→
   134→  return aestMinutes >= startMinutes && aestMinutes < endMinutes;
   135→}
   136→
   137→/**
   138→ * Broadcast notification to multiple users
   139→ * @param {object} notification - Notification payload
   140→ * @param {string} role - Optional role filter (e.g., 'closer')
   141→ * @returns {Promise<{totalSent: number}>}
   142→ */
   143→async function broadcastPush(notification, role = null) {
   144→  try {
   145→    // Get all users with push subscriptions
   146→    const { data: subscriptions } = await db
   147→      .from('push_subscriptions')
   148→      .select('user_id')
   149→      .order('user_id');
   150→
   151→    if (!subscriptions?.length) {
   152→      console.log('[Push] No subscriptions to broadcast to');
   153→      return { totalSent: 0 };
   154→    }
   155→
   156→    // Get unique user IDs
   157→    const userIds = [...new Set(subscriptions.map(s => s.user_id))];
   158→
   159→    // If role filter, check user_xp table
   160→    let filteredUserIds = userIds;
   161→    if (role) {
   162→      const { data: users } = await db
   163→        .from('user_xp')
   164→        .select('user_id')
   165→        .eq('role', role)
   166→        .in('user_id', userIds);
   167→
   168→      filteredUserIds = users?.map(u => u.user_id) || [];
   169→    }
   170→
   171→    let totalSent = 0;
   172→    for (const userId of filteredUserIds) {
   173→      const result = await sendPushNotification(userId, notification);
   174→      totalSent += result.sent;
   175→    }
   176→
   177→    console.log(`[Push] Broadcast sent to ${totalSent} subscriptions`);
   178→    return { totalSent };
   179→  } catch (error) {
   180→    console.error('[Push] Broadcast error:', error);
   181→    return { totalSent: 0 };
   182→  }
   183→}
   184→
   185→/**
   186→ * Send high-intent SMS notification
   187→ * @param {object} lead - Lead object
   188→ * @param {string} message - SMS message content
   189→ * @param {object} buyingSignal - Buying signal details
   190→ */
   191→async function notifyHighIntentSms(lead, message, buyingSignal) {
   192→  const leadName = `${lead?.first_name || ''} ${lead?.last_name || ''}`.trim() || 'Unknown';
   193→  const messagePreview = message?.substring(0, 50) + (message?.length > 50 ? '...' : '');
   194→
   195→  const notification = {
   196→    title: buyingSignal?.level === 'high' ? '🔥 HIGH INTENT' : '⚡ Buying Signal',
   197→    body: `${leadName}: "${messagePreview}"`,
   198→    tag: `sms-${lead?.id || Date.now()}`,
   199→    type: 'high_intent_sms',
   200→    priority: buyingSignal?.level === 'high' ? 'critical' : 'high',
   201→    vibrate: [200, 100, 200, 100, 200],
   202→    url: `/leads/${lead?.id}`,
   203→    leadId: lead?.id,
   204→    actions: [
   205→      { action: 'call', title: '📞 Call Now' },
   206→      { action: 'view', title: 'View' }
   207→    ]
   208→  };
   209→
   210→  // Send to assigned user or broadcast
   211→  if (lead?.assigned_to) {
   212→    return sendPushNotification(lead.assigned_to, notification);
   213→  } else {
   214→    return broadcastPush(notification, 'closer');
   215→  }
   216→}
   217→
   218→/**
   219→ * Send new signup notification
   220→ * @param {object} lead - New lead object
   221→ */
   222→async function notifyNewSignup(lead) {
   223→  const leadName = `${lead?.first_name || ''} ${lead?.last_name || ''}`.trim() || 'Unknown';
   224→  const trade = lead?.metadata?.trade || 'Unknown trade';
   225→  const location = lead?.metadata?.location || lead?.metadata?.suburb || 'Unknown';
   226→
   227→  const notification = {
   228→    title: '🆕 New Signup',
   229→    body: `${leadName} - ${trade} in ${location}`,
   230→    tag: `signup-${lead?.id}`,
   231→    type: 'new_leads',
   232→    priority: 'high',
   233→    url: `/leads/${lead?.id}`,
   234→    leadId: lead?.id
   235→  };
   236→
   237→  return broadcastPush(notification, 'closer');
   238→}
   239→
   240→/**
   241→ * Send callback reminder notification
   242→ * @param {object} callback - Callback object with lead data
   243→ * @param {number} minutesUntil - Minutes until callback is due
   244→ */
   245→async function notifyCallbackDue(callback, minutesUntil) {
   246→  const lead = callback.leads || callback.lead;
   247→  const leadName = `${lead?.first_name || ''} ${lead?.last_name || ''}`.trim() || 'Unknown';
   248→
   249→  const notification = {
   250→    title: `⏰ Callback in ${minutesUntil} min`,
   251→    body: `${leadName}${callback.reason ? ` - ${callback.reason}` : ''}`,
   252→    tag: `callback-${callback.id}`,
   253→    type: 'callbacks',
   254→    priority: 'high',
   255→    vibrate: [200, 100, 200],
   256→    url: `/leads/${lead?.id}?action=call`,
   257→    leadId: lead?.id,
   258→    actions: [
   259→      { action: 'call', title: '📞 Call Now' },
   260→      { action: 'snooze', title: '⏰ +15 min' }
   261→    ]
   262→  };
   263→
   264→  if (callback.user_id) {
   265→    return sendPushNotification(callback.user_id, notification);
   266→  }
   267→
   268→  return { sent: 0, reason: 'no_user_id' };
   269→}
   270→
   271→/**
   272→ * Send conversion celebration notification
   273→ * @param {object} lead - Converted lead
   274→ * @param {string} convertedBy - User who converted the lead
   275→ */
   276→async function notifyConversion(lead, convertedBy) {
   277→  const leadName = `${lead?.first_name || ''} ${lead?.last_name || ''}`.trim() || 'Unknown';
   278→
   279→  const notification = {
   280→    title: '🎉 Conversion!',
   281→    body: `${leadName} just converted!`,
   282→    tag: `conversion-${lead?.id}`,
   283→    type: 'conversions',
   284→    priority: 'medium',
   285→    url: `/leads/${lead?.id}`,
   286→    leadId: lead?.id
   287→  };
   288→
   289→  // Notify the closer who converted
   290→  if (convertedBy) {
   291→    await sendPushNotification(convertedBy, notification);
   292→  }
   293→
   294→  // Also broadcast to team
   295→  return broadcastPush(notification);
   296→}
   297→
   298→module.exports = {
   299→  sendPushNotification,
   300→  broadcastPush,
   301→  isQuietHours,
   302→  notifyHighIntentSms,
   303→  notifyNewSignup,
   304→  notifyCallbackDue,
   305→  notifyConversion
   306→};
   307→

<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→ * Weekly Intelligence Analysis Service
     3→ * Analyzes usage data to detect friction, find patterns, and suggest improvements
     4→ */
     5→
     6→const { supabase, supabaseAdmin } = require('../config/database');
     7→const OpenAI = require('openai');
     8→
     9→const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
    10→
    11→// Use admin client to bypass RLS for backend operations
    12→const db = supabaseAdmin || supabase;
    13→
    14→/**
    15→ * Run the full weekly analysis
    16→ */
    17→async function runWeeklyAnalysis() {
    18→  console.log('Starting weekly intelligence analysis...');
    19→
    20→  const weekStart = getWeekStart();
    21→  const weekEnd = new Date();
    22→
    23→  try {
    24→    // Gather all data
    25→    const [pageStats, flowStats, errorStats, aiStats] = await Promise.all([
    26→      getPageStats(weekStart, weekEnd),
    27→      getFlowStats(weekStart, weekEnd),
    28→      getErrorStats(weekStart, weekEnd),
    29→      getAIQualityStats(weekStart, weekEnd)
    30→    ]);
    31→
    32→    // Run AI analysis
    33→    const analysis = await analyzeWithAI({
    34→      pageStats,
    35→      flowStats,
    36→      errorStats,
    37→      aiStats,
    38→      weekStart,
    39→      weekEnd
    40→    });
    41→
    42→    // Store results
    43→    const report = await storeWeeklyReport({
    44→      weekStart,
    45→      weekEnd,
    46→      pageStats,
    47→      flowStats,
    48→      errorStats,
    49→      aiStats,
    50→      analysis
    51→    });
    52→
    53→    // Create suggestions from analysis
    54→    await createSuggestionsFromAnalysis(analysis, report.id);
    55→
    56→    console.log('Weekly analysis complete. Report ID:', report.id);
    57→    return report;
    58→  } catch (error) {
    59→    console.error('Weekly analysis failed:', error);
    60→    throw error;
    61→  }
    62→}
    63→
    64→/**
    65→ * Get the start of the current week (Monday)
    66→ */
    67→function getWeekStart() {
    68→  const now = new Date();
    69→  const day = now.getDay();
    70→  const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Sunday
    71→  const monday = new Date(now.setDate(diff));
    72→  monday.setHours(0, 0, 0, 0);
    73→  return monday;
    74→}
    75→
    76→/**
    77→ * Get page-level statistics
    78→ */
    79→async function getPageStats(startDate, endDate) {
    80→  const { data, error } = await db
    81→    .from('usage_events')
    82→    .select('page, event_type, duration_ms')
    83→    .gte('created_at', startDate.toISOString())
    84→    .lte('created_at', endDate.toISOString())
    85→    .not('page', 'is', null);
    86→
    87→  if (error) {
    88→    console.error('Error getting page stats:', error);
    89→    return [];
    90→  }
    91→
    92→  // Aggregate by page
    93→  const pageMap = {};
    94→  for (const event of data || []) {
    95→    if (!pageMap[event.page]) {
    96→      pageMap[event.page] = {
    97→        page: event.page,
    98→        views: 0,
    99→        completions: 0,
   100→        abandons: 0,
   101→        rage_clicks: 0,
   102→        errors: 0,
   103→        total_duration: 0,
   104→        duration_count: 0
   105→      };
   106→    }
   107→
   108→    const stats = pageMap[event.page];
   109→    switch (event.event_type) {
   110→      case 'page_view':
   111→        stats.views++;
   112→        break;
   113→      case 'flow_complete':
   114→        stats.completions++;
   115→        break;
   116→      case 'flow_abandon':
   117→        stats.abandons++;
   118→        break;
   119→      case 'rage_click':
   120→        stats.rage_clicks++;
   121→        break;
   122→      case 'error':
   123→        stats.errors++;
   124→        break;
   125→    }
   126→
   127→    if (event.duration_ms) {
   128→      stats.total_duration += event.duration_ms;
   129→      stats.duration_count++;
   130→    }
   131→  }
   132→
   133→  // Calculate rates and sort by views
   134→  return Object.values(pageMap)
   135→    .map(stats => ({
   136→      ...stats,
   137→      avg_duration_ms: stats.duration_count > 0
   138→        ? Math.round(stats.total_duration / stats.duration_count)
   139→        : null,
   140→      abandonment_rate: stats.views > 0
   141→        ? Math.round((stats.abandons / stats.views) * 100)
   142→        : 0,
   143→      completion_rate: stats.views > 0
   144→        ? Math.round((stats.completions / stats.views) * 100)
   145→        : 0
   146→    }))
   147→    .sort((a, b) => b.views - a.views);
   148→}
   149→
   150→/**
   151→ * Get flow-level statistics (key user journeys)
   152→ */
   153→async function getFlowStats(startDate, endDate) {
   154→  const { data, error } = await db
   155→    .from('usage_events')
   156→    .select('event_type, event_name, page, context, duration_ms')
   157→    .gte('created_at', startDate.toISOString())
   158→    .lte('created_at', endDate.toISOString())
   159→    .in('event_type', ['flow_complete', 'flow_abandon']);
   160→
   161→  if (error) {
   162→    console.error('Error getting flow stats:', error);
   163→    return [];
   164→  }
   165→
   166→  // Group by flow name
   167→  const flowMap = {};
   168→  for (const event of data || []) {
   169→    const flowName = event.event_name.replace('_complete', '').replace('_abandon', '');
   170→
   171→    if (!flowMap[flowName]) {
   172→      flowMap[flowName] = {
   173→        name: flowName,
   174→        completions: 0,
   175→        abandons: 0,
   176→        total_duration: 0,
   177→        duration_count: 0,
   178→        abandon_steps: {}
   179→      };
   180→    }
   181→
   182→    const flow = flowMap[flowName];
   183→    if (event.event_type === 'flow_complete') {
   184→      flow.completions++;
   185→    } else {
   186→      flow.abandons++;
   187→      const step = event.context?.abandoned_at_step || 'unknown';
   188→      flow.abandon_steps[step] = (flow.abandon_steps[step] || 0) + 1;
   189→    }
   190→
   191→    if (event.duration_ms) {
   192→      flow.total_duration += event.duration_ms;
   193→      flow.duration_count++;
   194→    }
   195→  }
   196→
   197→  return Object.values(flowMap)
   198→    .map(flow => ({
   199→      ...flow,
   200→      avg_duration_ms: flow.duration_count > 0
   201→        ? Math.round(flow.total_duration / flow.duration_count)
   202→        : null,
   203→      completion_rate: (flow.completions + flow.abandons) > 0
   204→        ? Math.round((flow.completions / (flow.completions + flow.abandons)) * 100)
   205→        : 0,
   206→      top_abandon_step: Object.entries(flow.abandon_steps)
   207→        .sort((a, b) => b[1] - a[1])[0]?.[0] || null
   208→    }))
   209→    .sort((a, b) => (b.completions + b.abandons) - (a.completions + a.abandons));
   210→}
   211→
   212→/**
   213→ * Get error statistics
   214→ */
   215→async function getErrorStats(startDate, endDate) {
   216→  const { data, error } = await db
   217→    .from('usage_events')
   218→    .select('page, event_name, context')
   219→    .gte('created_at', startDate.toISOString())
   220→    .lte('created_at', endDate.toISOString())
   221→    .eq('event_type', 'error');
   222→
   223→  if (error) {
   224→    console.error('Error getting error stats:', error);
   225→    return [];
   226→  }
   227→
   228→  // Group by error type and page
   229→  const errorMap = {};
   230→  for (const event of data || []) {
   231→    const key = `${event.page || 'unknown'}_${event.event_name}`;
   232→    if (!errorMap[key]) {
   233→      errorMap[key] = {
   234→        page: event.page || 'unknown',
   235→        error_type: event.event_name,
   236→        count: 0,
   237→        sample_messages: []
   238→      };
   239→    }
   240→
   241→    errorMap[key].count++;
   242→    if (errorMap[key].sample_messages.length < 3 && event.context?.error_message) {
   243→      errorMap[key].sample_messages.push(event.context.error_message);
   244→    }
   245→  }
   246→
   247→  return Object.values(errorMap)
   248→    .sort((a, b) => b.count - a.count)
   249→    .slice(0, 10); // Top 10 errors
   250→}
   251→
   252→/**
   253→ * Get AI feature quality stats (from suggestion_feedback table)
   254→ */
   255→async function getAIQualityStats(startDate, endDate) {
   256→  // Try to get AI suggestion feedback
   257→  const { data: feedback, error: feedbackError } = await db
   258→    .from('suggestion_feedback')
   259→    .select('feature, feedback')
   260→    .gte('created_at', startDate.toISOString())
   261→    .lte('created_at', endDate.toISOString());
   262→
   263→  if (feedbackError) {
   264→    console.log('No suggestion_feedback table or error:', feedbackError.message);
   265→    return { total: 0, positive: 0, features: {} };
   266→  }
   267→
   268→  // Aggregate by feature
   269→  const featureMap = {};
   270→  let totalPositive = 0;
   271→  let total = 0;
   272→
   273→  for (const fb of feedback || []) {
   274→    if (!featureMap[fb.feature]) {
   275→      featureMap[fb.feature] = { positive: 0, negative: 0 };
   276→    }
   277→
   278→    if (fb.feedback === 'positive') {
   279→      featureMap[fb.feature].positive++;
   280→      totalPositive++;
   281→    } else {
   282→      featureMap[fb.feature].negative++;
   283→    }
   284→    total++;
   285→  }
   286→
   287→  return {
   288→    total,
   289→    positive: totalPositive,
   290→    acceptance_rate: total > 0 ? Math.round((totalPositive / total) * 100) : null,
   291→    features: featureMap
   292→  };
   293→}
   294→
   295→/**
   296→ * Use AI to analyze the data and generate insights
   297→ */
   298→async function analyzeWithAI(data) {
   299→  const { pageStats, flowStats, errorStats, aiStats } = data;
   300→
   301→  // Skip AI if no data
   302→  if (pageStats.length === 0 && flowStats.length === 0) {
   303→    return {
   304→      friction_points: [],
   305→      quick_wins: [],
   306→      patterns: [],
   307→      unused_features: [],
   308→      summary: 'Not enough data to analyze. Need more usage events.'
   309→    };
   310→  }
   311→
   312→  const prompt = `You are analyzing usage data for a sales CRM application. Identify friction points, quick wins, and patterns.
   313→
   314→PAGE STATISTICS (Last 7 days):
   315→${JSON.stringify(pageStats.slice(0, 10), null, 2)}
   316→
   317→FLOW STATISTICS:
   318→${JSON.stringify(flowStats.slice(0, 10), null, 2)}
   319→
   320→ERROR STATISTICS:
   321→${JSON.stringify(errorStats, null, 2)}
   322→
   323→AI QUALITY:
   324→${JSON.stringify(aiStats, null, 2)}
   325→
   326→Based on this data, provide a JSON response with:
   327→1. friction_points: Array of { location, issue, evidence, severity (1-100), suggested_fix, effort (low/medium/high) }
   328→2. quick_wins: Array of { title, description, impact, effort (low/medium/high), priority_score (1-100) }
   329→3. patterns: Array of { pattern, evidence, actionable_insight }
   330→4. unused_features: Array of { feature, current_usage, recommendation (promote/simplify/remove/investigate) }
   331→5. summary: 2-3 sentence executive summary
   332→
   333→Focus on:
   334→- High abandonment rates (>30% is concerning)
   335→- Rage clicks indicate frustration
   336→- Errors affecting many users
   337→- Declining AI acceptance rates
   338→- Quick wins = high impact + low effort
   339→
   340→Respond with ONLY valid JSON, no markdown.`;
   341→
   342→  try {
   343→    const response = await openai.chat.completions.create({
   344→      model: 'gpt-4o',
   345→      messages: [{ role: 'user', content: prompt }],
   346→      temperature: 0.3,
   347→      response_format: { type: 'json_object' }
   348→    });
   349→
   350→    const analysis = JSON.parse(response.choices[0].message.content);
   351→    return analysis;
   352→  } catch (error) {
   353→    console.error('AI analysis failed:', error);
   354→    return {
   355→      friction_points: [],
   356→      quick_wins: [],
   357→      patterns: [],
   358→      unused_features: [],
   359→      summary: 'AI analysis failed. Manual review recommended.'
   360→    };
   361→  }
   362→}
   363→
   364→/**
   365→ * Store the weekly report in the database
   366→ */
   367→async function storeWeeklyReport(data) {
   368→  const { weekStart, weekEnd, pageStats, flowStats, errorStats, aiStats, analysis } = data;
   369→
   370→  // Calculate overall scores
   371→  const avgAbandonRate = pageStats.length > 0
   372→    ? pageStats.reduce((sum, p) => sum + p.abandonment_rate, 0) / pageStats.length
   373→    : 0;
   374→
   375→  const frictionScore = Math.max(0, 100 - avgAbandonRate - (errorStats.length * 5));
   376→  const healthScore = Math.round(
   377→    (frictionScore * 0.4) +
   378→    ((aiStats.acceptance_rate || 70) * 0.3) +
   379→    (70 * 0.3) // Base score
   380→  );
   381→
   382→  const reportData = {
   383→    week_start: weekStart.toISOString().split('T')[0],
   384→    week_end: weekEnd.toISOString().split('T')[0],
   385→    friction_points: analysis.friction_points || [],
   386→    quick_wins: analysis.quick_wins || [],
   387→    patterns_detected: analysis.patterns || [],
   388→    unused_features: analysis.unused_features || [],
   389→    ai_quality: aiStats,
   390→    overall_health_score: healthScore,
   391→    friction_score: frictionScore,
   392→    improvement_opportunities: (analysis.quick_wins?.length || 0) + (analysis.friction_points?.length || 0),
   393→    full_report_json: {
   394→      pageStats,
   395→      flowStats,
   396→      errorStats,
   397→      aiStats,
   398→      analysis
   399→    }
   400→  };
   401→
   402→  const { data: report, error } = await db
   403→    .from('weekly_excellence_reports')
   404→    .upsert(reportData, { onConflict: 'week_start' })
   405→    .select()
   406→    .single();
   407→
   408→  if (error) {
   409→    console.error('Error storing weekly report:', error);
   410→    throw error;
   411→  }
   412→
   413→  return report;
   414→}
   415→
   416→/**
   417→ * Create suggestions from the AI analysis
   418→ */
   419→async function createSuggestionsFromAnalysis(analysis, reportId) {
   420→  const suggestions = [];
   421→
   422→  // Create suggestions from quick wins
   423→  for (const win of (analysis.quick_wins || [])) {
   424→    suggestions.push({
   425→      suggestion_type: 'quick_win',
   426→      title: win.title,
   427→      description: win.description,
   428→      evidence: { impact: win.impact },
   429→      predicted_impact: win.impact,
   430→      predicted_effort: win.effort,
   431→      priority_score: win.priority_score || 50,
   432→      source: 'weekly_review',
   433→      source_report_id: reportId,
   434→      status: 'suggested'
   435→    });
   436→  }
   437→
   438→  // Create suggestions from friction points
   439→  for (const friction of (analysis.friction_points || [])) {
   440→    suggestions.push({
   441→      suggestion_type: 'friction_fix',
   442→      title: `Fix friction: ${friction.location}`,
   443→      description: `${friction.issue}\n\nSuggested fix: ${friction.suggested_fix}`,
   444→      evidence: { evidence: friction.evidence, severity: friction.severity },
   445→      predicted_impact: `Reduce friction at ${friction.location}`,
   446→      predicted_effort: friction.effort,
   447→      priority_score: friction.severity || 50,
   448→      source: 'weekly_review',
   449→      source_report_id: reportId,
   450→      status: 'suggested'
   451→    });
   452→
   453→    // Also store as detected friction
   454→    await db.from('detected_friction').insert({
   455→      detection_date: new Date().toISOString().split('T')[0],
   456→      friction_type: 'ai_detected',
   457→      location: friction.location,
   458→      severity_score: friction.severity || 50,
   459→      evidence: { issue: friction.issue, evidence: friction.evidence },
   460→      root_cause_hypothesis: friction.issue,
   461→      suggested_fix: friction.suggested_fix,
   462→      estimated_effort: friction.effort,
   463→      status: 'detected'
   464→    });
   465→  }
   466→
   467→  if (suggestions.length > 0) {
   468→    const { error } = await db
   469→      .from('intelligence_suggestions')
   470→      .insert(suggestions);
   471→
   472→    if (error) {
   473→      console.error('Error creating suggestions:', error);
   474→    } else {
   475→      console.log(`Created ${suggestions.length} suggestions from analysis`);
   476→    }
   477→  }
   478→}
   479→
   480→/**
   481→ * Get the latest weekly report
   482→ */
   483→async function getLatestReport() {
   484→  const { data, error } = await db
   485→    .from('weekly_excellence_reports')
   486→    .select('*')
   487→    .order('week_start', { ascending: false })
   488→    .limit(1)
   489→    .single();
   490→
   491→  if (error) {
   492→    console.error('Error getting latest report:', error);
   493→    return null;
   494→  }
   495→
   496→  return data;
   497→}
   498→
   499→module.exports = {
   500→  runWeeklyAnalysis,
   501→  getLatestReport,
   502→  getPageStats,
   503→  getFlowStats,
   504→  getErrorStats,
   505→  getAIQualityStats
   506→};
   507→

<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→ * Twilio Media Streams → Deepgram Transcription
     3→ *
     4→ * This handles audio from BOTH sides of a Twilio call:
     5→ * - Inbound track: What the other person says (customer)
     6→ * - Outbound track: What you say (agent)
     7→ *
     8→ * Twilio sends: mulaw audio, 8kHz, base64 encoded
     9→ * Deepgram needs: mulaw audio, 8kHz (can handle it directly!)
    10→ */
    11→
    12→const WebSocket = require('ws');
    13→const { supabase } = require('../config/database');
    14→
    15→// Store active streams: callSid -> { deepgramWs, transcripts, startTime, etc }
    16→const activeStreams = new Map();
    17→
    18→/**
    19→ * Get Deepgram config for Twilio mulaw audio
    20→ */
    21→function getDeepgramConfigForTwilio() {
    22→  const params = new URLSearchParams({
    23→    model: 'nova-2',
    24→    language: 'en-AU',
    25→    encoding: 'mulaw',        // Twilio sends mulaw
    26→    sample_rate: '8000',      // Twilio uses 8kHz
    27→    channels: '2',            // Stereo: inbound + outbound
    28→    multichannel: 'true',     // Transcribe each channel separately
    29→    smart_format: 'true',
    30→    interim_results: 'true',
    31→    punctuate: 'true',
    32→    diarize: 'false',         // We already know who's speaking from channels
    33→    endpointing: '300',
    34→  });
    35→
    36→  return {
    37→    url: `wss://api.deepgram.com/v1/listen?${params.toString()}`,
    38→    apiKey: process.env.DEEPGRAM_API_KEY,
    39→  };
    40→}
    41→
    42→/**
    43→ * Handle incoming Twilio Media Stream WebSocket connection
    44→ */
    45→function handleTwilioMediaStream(ws, req) {
    46→  console.log('[TwilioMedia] New connection');
    47→
    48→  let callSid = null;
    49→  let streamSid = null;
    50→  let deepgramWs = null;
    51→  let inboundBuffer = [];
    52→  let outboundBuffer = [];
    53→  let sequenceNumbers = { inbound: -1, outbound: -1 };
    54→
    55→  ws.on('message', async (message) => {
    56→    try {
    57→      const msg = JSON.parse(message);
    58→
    59→      switch (msg.event) {
    60→        case 'connected':
    61→          console.log('[TwilioMedia] Connected:', msg.protocol);
    62→          break;
    63→
    64→        case 'start':
    65→          // Stream started - contains metadata
    66→          callSid = msg.start.callSid;
    67→          streamSid = msg.start.streamSid;
    68→          const customParams = msg.start.customParameters || {};
    69→
    70→          console.log('[TwilioMedia] Stream started:', {
    71→            callSid,
    72→            streamSid,
    73→            tracks: msg.start.tracks,
    74→            mediaFormat: msg.start.mediaFormat,
    75→            customParams
    76→          });
    77→
    78→          // Connect to Deepgram
    79→          deepgramWs = await connectToDeepgram(callSid, customParams.leadId);
    80→
    81→          // Store stream info with transcript accumulator
    82→          activeStreams.set(callSid, {
    83→            deepgramWs,
    84→            streamSid,
    85→            twilioWs: ws,
    86→            leadId: customParams.leadId,
    87→            transcripts: [], // Accumulate all transcripts
    88→            startTime: Date.now(),
    89→          });
    90→          break;
    91→
    92→        case 'media':
    93→          // Audio data from Twilio
    94→          if (!deepgramWs || deepgramWs.readyState !== WebSocket.OPEN) {
    95→            return;
    96→          }
    97→
    98→          const track = msg.media.track; // 'inbound' or 'outbound'
    99→          const payload = msg.media.payload; // base64 encoded mulaw audio
   100→          const timestamp = parseInt(msg.media.timestamp);
   101→          const sequence = parseInt(msg.media.chunk);
   102→
   103→          // Decode base64 to buffer
   104→          const audioBuffer = Buffer.from(payload, 'base64');
   105→
   106→          // For stereo mixing, we need to interleave inbound and outbound
   107→          // Each sample is 1 byte (mulaw is 8-bit)
   108→          if (track === 'inbound') {
   109→            inboundBuffer.push({ audio: audioBuffer, seq: sequence, ts: timestamp });
   110→          } else if (track === 'outbound') {
   111→            outboundBuffer.push({ audio: audioBuffer, seq: sequence, ts: timestamp });
   112→          }
   113→
   114→          // Mix and send when we have data from both tracks
   115→          // (or after timeout if one track is silent)
   116→          mixAndSendAudio(deepgramWs, inboundBuffer, outboundBuffer);
   117→          break;
   118→
   119→        case 'stop':
   120→          console.log('[TwilioMedia] Stream stopped:', {
   121→            callSid,
   122→            reason: msg.stop?.reason
   123→          });
   124→
   125→          // Save transcript before cleanup
   126→          await saveCallTranscript(callSid);
   127→
   128→          // Close Deepgram connection
   129→          if (deepgramWs && deepgramWs.readyState === WebSocket.OPEN) {
   130→            deepgramWs.close();
   131→          }
   132→          activeStreams.delete(callSid);
   133→          break;
   134→
   135→        case 'mark':
   136→          // Acknowledgment that audio was played (bidirectional only)
   137→          console.log('[TwilioMedia] Mark:', msg.mark?.name);
   138→          break;
   139→
   140→        default:
   141→          console.log('[TwilioMedia] Unknown event:', msg.event);
   142→      }
   143→    } catch (error) {
   144→      console.error('[TwilioMedia] Error processing message:', error);
   145→    }
   146→  });
   147→
   148→  ws.on('close', async () => {
   149→    console.log('[TwilioMedia] Connection closed');
   150→
   151→    // Save transcript if not already saved
   152→    if (callSid && activeStreams.has(callSid)) {
   153→      await saveCallTranscript(callSid);
   154→      activeStreams.delete(callSid);
   155→    }
   156→
   157→    if (deepgramWs && deepgramWs.readyState === WebSocket.OPEN) {
   158→      deepgramWs.close();
   159→    }
   160→  });
   161→
   162→  ws.on('error', (error) => {
   163→    console.error('[TwilioMedia] WebSocket error:', error);
   164→  });
   165→}
   166→
   167→/**
   168→ * Connect to Deepgram for transcription
   169→ */
   170→async function connectToDeepgram(callSid, leadId) {
   171→  return new Promise((resolve, reject) => {
   172→    const config = getDeepgramConfigForTwilio();
   173→
   174→    console.log('[TwilioMedia] Connecting to Deepgram:', config.url);
   175→
   176→    const ws = new WebSocket(config.url, {
   177→      headers: {
   178→        'Authorization': `Token ${config.apiKey}`,
   179→      },
   180→    });
   181→
   182→    ws.on('open', () => {
   183→      console.log('[TwilioMedia] Deepgram connected for call:', callSid);
   184→      resolve(ws);
   185→    });
   186→
   187→    ws.on('message', (data) => {
   188→      try {
   189→        const response = JSON.parse(data);
   190→
   191→        if (response.type === 'Results') {
   192→          const channelIndex = response.channel_index?.[0] || 0;
   193→          const transcript = response.channel?.alternatives?.[0]?.transcript || '';
   194→          const isFinal = response.is_final;
   195→          const speaker = channelIndex === 0 ? 'customer' : 'agent';
   196→
   197→          if (transcript) {
   198→            console.log(`[TwilioMedia] [${speaker}] ${isFinal ? '✓' : '...'} ${transcript}`);
   199→
   200→            // Broadcast to connected clients
   201→            broadcastTranscript(callSid, {
   202→              speaker,
   203→              transcript,
   204→              isFinal,
   205→              timestamp: response.start,
   206→              duration: response.duration,
   207→              leadId,
   208→            });
   209→          }
   210→        } else if (response.type === 'SpeechStarted') {
   211→          const channelIndex = response.channel?.[0] || 0;
   212→          const speaker = channelIndex === 0 ? 'customer' : 'agent';
   213→          console.log(`[TwilioMedia] Speech started: ${speaker}`);
   214→        }
   215→      } catch (error) {
   216→        console.error('[TwilioMedia] Error parsing Deepgram message:', error);
   217→      }
   218→    });
   219→
   220→    ws.on('close', (code, reason) => {
   221→      console.log('[TwilioMedia] Deepgram closed:', code, reason?.toString());
   222→    });
   223→
   224→    ws.on('error', (error) => {
   225→      console.error('[TwilioMedia] Deepgram error:', error);
   226→      reject(error);
   227→    });
   228→  });
   229→}
   230→
   231→/**
   232→ * Mix inbound and outbound audio into stereo and send to Deepgram
   233→ * Channel 0 = inbound (customer), Channel 1 = outbound (agent)
   234→ */
   235→function mixAndSendAudio(deepgramWs, inboundBuffer, outboundBuffer) {
   236→  // Process when we have at least one chunk from each (or buffer gets too big)
   237→  while (inboundBuffer.length > 0 && outboundBuffer.length > 0) {
   238→    const inbound = inboundBuffer.shift();
   239→    const outbound = outboundBuffer.shift();
   240→
   241→    // Interleave: for each sample position, alternate inbound/outbound
   242→    // mulaw is 8-bit, so each sample is 1 byte
   243→    const inLen = inbound.audio.length;
   244→    const outLen = outbound.audio.length;
   245→    const maxLen = Math.max(inLen, outLen);
   246→
   247→    // Create stereo buffer (2 bytes per sample position: L + R)
   248→    const stereoBuffer = Buffer.alloc(maxLen * 2);
   249→
   250→    for (let i = 0; i < maxLen; i++) {
   251→      // Channel 0 (left) = inbound (customer)
   252→      stereoBuffer[i * 2] = i < inLen ? inbound.audio[i] : 0x7F; // 0x7F = mulaw silence
   253→      // Channel 1 (right) = outbound (agent)
   254→      stereoBuffer[i * 2 + 1] = i < outLen ? outbound.audio[i] : 0x7F;
   255→    }
   256→
   257→    if (deepgramWs.readyState === WebSocket.OPEN) {
   258→      deepgramWs.send(stereoBuffer);
   259→    }
   260→  }
   261→
   262→  // Handle case where only one side is talking (flush old buffers)
   263→  const MAX_BUFFER = 10;
   264→  while (inboundBuffer.length > MAX_BUFFER) {
   265→    const inbound = inboundBuffer.shift();
   266→    // Send as mono on channel 0
   267→    if (deepgramWs.readyState === WebSocket.OPEN) {
   268→      const stereoBuffer = Buffer.alloc(inbound.audio.length * 2);
   269→      for (let i = 0; i < inbound.audio.length; i++) {
   270→        stereoBuffer[i * 2] = inbound.audio[i];
   271→        stereoBuffer[i * 2 + 1] = 0x7F; // silence on outbound channel
   272→      }
   273→      deepgramWs.send(stereoBuffer);
   274→    }
   275→  }
   276→
   277→  while (outboundBuffer.length > MAX_BUFFER) {
   278→    const outbound = outboundBuffer.shift();
   279→    // Send as mono on channel 1
   280→    if (deepgramWs.readyState === WebSocket.OPEN) {
   281→      const stereoBuffer = Buffer.alloc(outbound.audio.length * 2);
   282→      for (let i = 0; i < outbound.audio.length; i++) {
   283→        stereoBuffer[i * 2] = 0x7F; // silence on inbound channel
   284→        stereoBuffer[i * 2 + 1] = outbound.audio[i];
   285→      }
   286→      deepgramWs.send(stereoBuffer);
   287→    }
   288→  }
   289→}
   290→
   291→/**
   292→ * Broadcast transcript and accumulate for saving
   293→ */
   294→function broadcastTranscript(callSid, data) {
   295→  const stream = activeStreams.get(callSid);
   296→  if (!stream) return;
   297→
   298→  // Accumulate final transcripts for saving
   299→  if (data.isFinal && data.transcript) {
   300→    const prefix = data.speaker === 'customer' ? 'Customer: ' : 'Agent: ';
   301→    stream.transcripts.push(prefix + data.transcript);
   302→    console.log(`[TwilioMedia] Accumulated: ${prefix}${data.transcript}`);
   303→  }
   304→
   305→  // Emit to any subscribers
   306→  if (stream.onTranscript) {
   307→    stream.onTranscript(data);
   308→  }
   309→}
   310→
   311→/**
   312→ * Save call transcript to database when stream ends
   313→ */
   314→async function saveCallTranscript(callSid) {
   315→  const stream = activeStreams.get(callSid);
   316→  if (!stream || !stream.leadId) {
   317→    console.log('[TwilioMedia] No stream or leadId to save transcript');
   318→    return;
   319→  }
   320→
   321→  const fullTranscript = stream.transcripts.join('\n');
   322→  const duration = Math.round((Date.now() - stream.startTime) / 1000);
   323→
   324→  if (fullTranscript.length < 10) {
   325→    console.log('[TwilioMedia] Transcript too short, not saving:', fullTranscript.length, 'chars');
   326→    return;
   327→  }
   328→
   329→  console.log(`[TwilioMedia] Saving transcript for lead ${stream.leadId}: ${fullTranscript.length} chars, ${duration}s`);
   330→
   331→  try {
   332→    // Save to copilot_sessions
   333→    const { error } = await supabase
   334→      .from('copilot_sessions')
   335→      .insert({
   336→        lead_id: stream.leadId,
   337→        transcript: fullTranscript,
   338→        duration_seconds: duration,
   339→      });
   340→
   341→    if (error) {
   342→      console.error('[TwilioMedia] Failed to save transcript:', error.message);
   343→    } else {
   344→      console.log('[TwilioMedia] Transcript saved successfully');
   345→    }
   346→  } catch (err) {
   347→    console.error('[TwilioMedia] Error saving transcript:', err.message);
   348→  }
   349→}
   350→
   351→/**
   352→ * Subscribe to transcripts for a specific call
   353→ */
   354→function subscribeToTranscripts(callSid, callback) {
   355→  const stream = activeStreams.get(callSid);
   356→  if (stream) {
   357→    stream.onTranscript = callback;
   358→  }
   359→}
   360→
   361→module.exports = {
   362→  handleTwilioMediaStream,
   363→  subscribeToTranscripts,
   364→  activeStreams,
   365→};
   366→

<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.


     1→/**
     2→ * Setup Twilio Media Stream WebSocket endpoint
     3→ * Call this after creating your HTTP server
     4→ */
     5→
     6→const WebSocket = require('ws');
     7→const { handleTwilioMediaStream } = require('./twilioMedia');
     8→
     9→function setupTwilioMediaStreams(server) {
    10→  // Create WebSocket server for Twilio Media Streams
    11→  // Using same pattern as Deepgram proxy (server + path) for consistency
    12→  const wss = new WebSocket.Server({
    13→    server,
    14→    path: '/api/twilio-media',
    15→  });
    16→
    17→  // Handle new Twilio Media Stream connections
    18→  wss.on('connection', (ws, request) => {
    19→    console.log('[TwilioMedia] New WebSocket connection from Twilio');
    20→    console.log('[TwilioMedia] Request URL:', request.url);
    21→    console.log('[TwilioMedia] Headers:', JSON.stringify(request.headers, null, 2));
    22→    handleTwilioMediaStream(ws, request);
    23→  });
    24→
    25→  wss.on('error', (error) => {
    26→    console.error('[TwilioMedia] WebSocket server error:', error);
    27→  });
    28→
    29→  console.log('[TwilioMedia] WebSocket endpoint ready: /api/twilio-media');
    30→
    31→  return wss;
    32→}
    33→
    34→module.exports = { setupTwilioMediaStreams };
    35→

<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→ * SMS Template service
     3→ * Handles template retrieval and variable substitution
     4→ */
     5→
     6→const { supabase } = require('../config/database');
     7→
     8→/**
     9→ * Get a template by slug
    10→ * @param {string} slug - Template slug
    11→ * @returns {Promise<object|null>}
    12→ */
    13→async function getTemplate(slug) {
    14→  if (!supabase) return null;
    15→
    16→  const { data, error } = await supabase
    17→    .from('sms_templates')
    18→    .select('*')
    19→    .eq('slug', slug)
    20→    .eq('is_active', true)
    21→    .single();
    22→
    23→  if (error) {
    24→    console.error('Template fetch error:', error.message);
    25→    return null;
    26→  }
    27→
    28→  return data;
    29→}
    30→
    31→/**
    32→ * Get all active templates
    33→ * @returns {Promise<Array>}
    34→ */
    35→async function getAllTemplates() {
    36→  if (!supabase) return [];
    37→
    38→  const { data, error } = await supabase
    39→    .from('sms_templates')
    40→    .select('*')
    41→    .eq('is_active', true)
    42→    .order('name');
    43→
    44→  if (error) {
    45→    console.error('Templates fetch error:', error.message);
    46→    return [];
    47→  }
    48→
    49→  return data || [];
    50→}
    51→
    52→/**
    53→ * Render a template with variables
    54→ * @param {string} content - Template content with {{variables}}
    55→ * @param {object} variables - Variable values
    56→ * @returns {string} Rendered content
    57→ */
    58→function renderTemplate(content, variables = {}) {
    59→  if (!content) return '';
    60→
    61→  let rendered = content;
    62→
    63→  for (const [key, value] of Object.entries(variables)) {
    64→    const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
    65→    rendered = rendered.replace(regex, value || '');
    66→  }
    67→
    68→  // Remove any remaining unmatched variables
    69→  rendered = rendered.replace(/{{\s*\w+\s*}}/g, '');
    70→
    71→  return rendered.trim();
    72→}
    73→
    74→/**
    75→ * Build variables from a lead object
    76→ * @param {object} lead - Lead record
    77→ * @returns {object} Variables for template rendering
    78→ */
    79→function buildLeadVariables(lead) {
    80→  if (!lead) return {};
    81→
    82→  return {
    83→    first_name: lead.first_name || '',
    84→    last_name: lead.last_name || '',
    85→    full_name: `${lead.first_name || ''} ${lead.last_name || ''}`.trim(),
    86→    company: lead.company || '',
    87→    email: lead.email || '',
    88→    phone: lead.phone || ''
    89→  };
    90→}
    91→
    92→module.exports = {
    93→  getTemplate,
    94→  getAllTemplates,
    95→  renderTemplate,
    96→  buildLeadVariables
    97→};
    98→

<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>