Read EVERY file in /home/ccuser/rateright-growth/src/services/ and provide a detailed audit. For each file report:
1. What functions it exports and their purpose
2. Bug risks — race conditions, unhandled promises, logic errors
3. Error handling — are errors caught properly? Are they swallowed silently?
4. Dead code — unused exports, commented-out code, stale functions
5. Security issues — API key exposure, SQL injection, unsafe string interpolation in queries
6. External API calls — what third-party services are used? Are responses validated?
7. Database queries — what tables are accessed? Any raw SQL injection risks?

The files to read are:
- challengesServiceV2.js, aiCallScoring.js, activationAI.js, challengesService.js
- badgesService.js, callListRanker.js, callAnalytics.js, promptManagement.js
- learning.js, duelsService.js, dealIntelligence.js, selfImprovement.js
- platformSync.js, perplexity.js, repPerformance.js, compliance.js
- openai.js, criticalAlerts.js, leaderboardService.js, deepgram.js
- scoring.js, pushNotifications.js, weeklyIntelligence.js
- twilioMedia.js, twilioMediaSetup.js, templates.js, slack.js
- xpService.js, wisdom.js, stageDiagnosis.js, stageCalculator.js
- twimlWithStream.js, qualityAudit.js, ai.js, notifications.js
- taskExecutor.js, taskParser.js, exportService.js, webhookDelivery.js
- enrichment.js, jobLocking.js, twilio.js, cache.js

Be thorough and brutal. Report EVERYTHING with specific line numbers and code snippets.


I'll start by reading the memory files as instructed, then systematically audit every file in the services directory. Let me begin by reading the memory files and the first batch of service files.


<tool_use_error>File does not exist.</tool_use_error>


<tool_use_error>Sibling tool call errored</tool_use_error>


Bash

List all service files


List all service files


total 672
drwxr-xr-x 2 ccuser ccuser  4096 Feb  5 22:09 .
drwxr-xr-x 8 ccuser ccuser  4096 Feb  2 16:30 ..
-rw-r--r-- 1 ccuser ccuser 15480 Jan 30 19:24 activationAI.js
-rw-rw-r-- 1 ccuser ccuser 82622 Jan 31 01:49 ai.js
-rw-r--r-- 1 ccuser ccuser 12990 Jan 30 19:24 aiCallScoring.js
-rw-r--r-- 1 ccuser ccuser 10725 Jan 30 19:24 badgesService.js
-rw-r--r-- 1 root   root    5437 Feb  5 22:09 cache.js
-rw-r--r-- 1 ccuser ccuser 23030 Jan 30 19:24 callAnalytics.js
-rw-r--r-- 1 ccuser ccuser 17533 Jan 30 19:24 callListRanker.js
-rw-r--r-- 1 ccuser ccuser 12819 Jan 30 19:24 challengesService.js
-rw-r--r-- 1 ccuser ccuser  9379 Jan 30 19:24 challengesServiceV2.js
-rw-r--r-- 1 ccuser ccuser 15279 Jan 30 19:24 compliance.js
-rw-r--r-- 1 ccuser ccuser 16391 Jan 30 19:24 criticalAlerts.js
-rw-r--r-- 1 ccuser ccuser 16908 Jan 30 19:24 dealIntelligence.js
-rw-r--r-- 1 ccuser ccuser  1948 Jan 30 19:24 deepgram.js
-rw-r--r-- 1 ccuser ccuser 16229 Jan 30 19:24 duelsService.js
-rw-r--r-- 1 ccuser ccuser 10063 Feb  2 16:29 enrichment.js
-rw-r--r-- 1 ccuser ccuser  7986 Feb  2 16:25 exportService.js
-rw-r--r-- 1 ccuser ccuser  6013 Feb  3 00:06 jobLocking.js
-rw-r--r-- 1 ccuser ccuser 14614 Jan 30 19:24 leaderboardService.js
-rw-r--r-- 1 ccuser ccuser 14969 Jan 30 19:24 learning.js
-rw-rw-r-- 1 ccuser ccuser  2705 Jan 31 23:46 notifications.js
-rw-r--r-- 1 ccuser ccuser  1779 Jan 30 19:24 openai.js
-rw-r--r-- 1 ccuser ccuser 23568 Jan 30 19:24 perplexity.js
-rw-r--r-- 1 ccuser ccuser 15026 Jan 30 19:24 platformSync.js
-rw-r--r-- 1 ccuser ccuser  9624 Jan 30 19:24 promptManagement.js
-rw-r--r-- 1 ccuser ccuser  9343 Jan 30 19:24 pushNotifications.js
-rw-r--r-- 1 ccuser ccuser 36417 Jan 31 01:03 qualityAudit.js
-rw-r--r-- 1 ccuser ccuser 18705 Jan 30 19:24 repPerformance.js
-rw-r--r-- 1 ccuser ccuser  8938 Jan 30 19:24 scoring.js
-rw-r--r-- 1 ccuser ccuser 13385 Jan 30 19:24 selfImprovement.js
-rw-r--r-- 1 ccuser ccuser 30618 Jan 30 19:24 slack.js
-rw-r--r-- 1 ccuser ccuser 12379 Jan 30 19:24 stageCalculator.js
-rw-r--r-- 1 ccuser ccuser 14212 Jan 30 19:24 stageDiagnosis.js
-rw-rw-r-- 1 ccuser ccuser 11735 Jan 31 23:46 taskExecutor.js
-rw-rw-r-- 1 ccuser ccuser  6815 Jan 31 23:46 taskParser.js
-rw-r--r-- 1 ccuser ccuser  2117 Jan 30 19:24 templates.js
-rw-r--r-- 1 ccuser ccuser  4780 Feb  3 18:12 twilio.js
-rw-r--r-- 1 ccuser ccuser 11426 Jan 30 19:24 twilioMedia.js
-rw-r--r-- 1 ccuser ccuser  1056 Jan 30 19:24 twilioMediaSetup.js
-rw-r--r-- 1 ccuser ccuser  2198 Jan 30 19:24 twimlWithStream.js
-rw-r--r-- 1 ccuser ccuser  7962 Feb  2 16:27 webhookDelivery.js
-rw-r--r-- 1 ccuser ccuser 13857 Jan 30 19:24 weeklyIntelligence.js
-rw-r--r-- 1 ccuser ccuser 13046 Jan 30 19:24 wisdom.js
-rw-r--r-- 1 ccuser ccuser  5625 Jan 30 19:24 xpService.js


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


Now let me read the first batch of files in parallel.


     1→/**
     2→ * Manager Challenges Service - Battleground V2
     3→ * Custom team challenges with real stakes
     4→ */
     5→
     6→const { supabase } = require('../config/database');
     7→
     8→// Challenge metrics
     9→const CHALLENGE_METRICS = {
    10→  calls: { name: 'Calls', unit: 'calls' },
    11→  conversions: { name: 'Conversions', unit: 'conversions' },
    12→  response_time: { name: 'Response Time', unit: 'seconds', lowerIsBetter: true },
    13→  talk_time: { name: 'Talk Time', unit: 'minutes' }
    14→};
    15→
    16→// Reward types
    17→const REWARD_TYPES = {
    18→  xp_multiplier: { name: 'XP Multiplier', description: 'Winner gets {value}x XP for the day' },
    19→  real_prize: { name: 'Real Prize', description: 'Winner gets: {description}' },
    20→  bragging_rights: { name: 'Bragging Rights', description: 'Winner gets trophy badge' }
    21→};
    22→
    23→/**
    24→ * Create a manager challenge
    25→ */
    26→async function createChallenge({
    27→  createdBy,
    28→  title,
    29→  description,
    30→  metric,
    31→  metricGoal = 'most',
    32→  targetValue,
    33→  rewardType,
    34→  rewardValue,
    35→  rewardDescription,
    36→  durationHours
    37→}) {
    38→  // Validate metric
    39→  if (!CHALLENGE_METRICS[metric]) {
    40→    throw new Error(`Invalid metric: ${metric}`);
    41→  }
    42→
    43→  // Calculate end time
    44→  const endsAt = new Date(Date.now() + durationHours * 60 * 60 * 1000);
    45→
    46→  const { data: challenge, error } = await supabase
    47→    .from('manager_challenges')
    48→    .insert({
    49→      created_by: createdBy,
    50→      title,
    51→      description,
    52→      metric,
    53→      metric_goal: metricGoal,
    54→      target_value: targetValue,
    55→      reward_type: rewardType,
    56→      reward_value: rewardValue,
    57→      reward_description: rewardDescription,
    58→      ends_at: endsAt.toISOString()
    59→    })
    60→    .select()
    61→    .single();
    62→
    63→  if (error) throw error;
    64→
    65→  return {
    66→    ...challenge,
    67→    metric_config: CHALLENGE_METRICS[metric],
    68→    reward_config: REWARD_TYPES[rewardType]
    69→  };
    70→}
    71→
    72→/**
    73→ * Join a challenge
    74→ */
    75→async function joinChallenge(challengeId, userId) {
    76→  // Check challenge exists and is active
    77→  const { data: challenge, error: fetchError } = await supabase
    78→    .from('manager_challenges')
    79→    .select('*')
    80→    .eq('id', challengeId)
    81→    .eq('status', 'active')
    82→    .single();
    83→
    84→  if (fetchError || !challenge) {
    85→    throw new Error('Challenge not found or not active');
    86→  }
    87→
    88→  // Check if already joined
    89→  const { data: existing } = await supabase
    90→    .from('challenge_participants')
    91→    .select('id')
    92→    .eq('challenge_id', challengeId)
    93→    .eq('user_id', userId)
    94→    .single();
    95→
    96→  if (existing) {
    97→    throw new Error('Already joined this challenge');
    98→  }
    99→
   100→  // Join
   101→  const { data: participant, error } = await supabase
   102→    .from('challenge_participants')
   103→    .insert({
   104→      challenge_id: challengeId,
   105→      user_id: userId
   106→    })
   107→    .select()
   108→    .single();
   109→
   110→  if (error) throw error;
   111→
   112→  return participant;
   113→}
   114→
   115→/**
   116→ * Get active challenges
   117→ */
   118→async function getActiveChallenges() {
   119→  const { data: challenges, error } = await supabase
   120→    .from('manager_challenges')
   121→    .select(`
   122→      *,
   123→      participants:challenge_participants(
   124→        user_id,
   125→        current_score
   126→      )
   127→    `)
   128→    .eq('status', 'active')
   129→    .order('ends_at', { ascending: true });
   130→
   131→  if (error) throw error;
   132→
   133→  return challenges.map(c => ({
   134→    ...c,
   135→    metric_config: CHALLENGE_METRICS[c.metric],
   136→    reward_config: REWARD_TYPES[c.reward_type],
   137→    participant_count: c.participants?.length || 0
   138→  }));
   139→}
   140→
   141→/**
   142→ * Get challenge details with leaderboard
   143→ */
   144→async function getChallengeDetails(challengeId) {
   145→  const { data: challenge, error } = await supabase
   146→    .from('manager_challenges')
   147→    .select(`
   148→      *,
   149→      participants:challenge_participants(
   150→        user_id,
   151→        current_score,
   152→        joined_at
   153→      )
   154→    `)
   155→    .eq('id', challengeId)
   156→    .single();
   157→
   158→  if (error) throw error;
   159→
   160→  // Sort participants by score
   161→  const sortedParticipants = (challenge.participants || []).sort((a, b) => {
   162→    if (CHALLENGE_METRICS[challenge.metric]?.lowerIsBetter) {
   163→      return a.current_score - b.current_score;
   164→    }
   165→    return b.current_score - a.current_score;
   166→  });
   167→
   168→  return {
   169→    ...challenge,
   170→    metric_config: CHALLENGE_METRICS[challenge.metric],
   171→    reward_config: REWARD_TYPES[challenge.reward_type],
   172→    leaderboard: sortedParticipants
   173→  };
   174→}
   175→
   176→/**
   177→ * Update scores for all participants in a challenge
   178→ */
   179→async function updateChallengeScores(challengeId) {
   180→  const { data: challenge, error: fetchError } = await supabase
   181→    .from('manager_challenges')
   182→    .select(`
   183→      *,
   184→      participants:challenge_participants(user_id)
   185→    `)
   186→    .eq('id', challengeId)
   187→    .eq('status', 'active')
   188→    .single();
   189→
   190→  if (fetchError || !challenge) return null;
   191→
   192→  const startTime = challenge.starts_at;
   193→
   194→  for (const participant of challenge.participants || []) {
   195→    let score = 0;
   196→
   197→    if (challenge.metric === 'calls') {
   198→      const { data: calls } = await supabase
   199→        .from('communications')
   200→        .select('id')
   201→        .eq('user_id', participant.user_id)
   202→        .eq('type', 'call_outbound')
   203→        .gte('created_at', startTime);
   204→
   205→      score = calls?.length || 0;
   206→    } else if (challenge.metric === 'conversions') {
   207→      const { data: conversions } = await supabase
   208→        .from('leads')
   209→        .select('id')
   210→        .eq('assigned_to', participant.user_id)
   211→        .eq('status', 'converted')
   212→        .gte('updated_at', startTime);
   213→
   214→      score = conversions?.length || 0;
   215→    } else if (challenge.metric === 'talk_time') {
   216→      const { data: calls } = await supabase
   217→        .from('communications')
   218→        .select('metadata')
   219→        .eq('user_id', participant.user_id)
   220→        .eq('type', 'call_outbound')
   221→        .gte('created_at', startTime);
   222→
   223→      // Sum duration from metadata
   224→      score = (calls || []).reduce((sum, c) => {
   225→        return sum + (c.metadata?.duration || 0);
   226→      }, 0) / 60; // Convert to minutes
   227→    }
   228→
   229→    // Update score
   230→    await supabase
   231→      .from('challenge_participants')
   232→      .update({ current_score: Math.round(score) })
   233→      .eq('challenge_id', challengeId)
   234→      .eq('user_id', participant.user_id);
   235→  }
   236→
   237→  return getChallengeDetails(challengeId);
   238→}
   239→
   240→/**
   241→ * Complete a challenge and determine winner
   242→ */
   243→async function completeChallenge(challengeId) {
   244→  // Update scores one final time
   245→  const challenge = await updateChallengeScores(challengeId);
   246→
   247→  if (!challenge || challenge.status !== 'active') {
   248→    throw new Error('Challenge not active');
   249→  }
   250→
   251→  // Determine winner
   252→  let winnerId = null;
   253→
   254→  if (challenge.leaderboard && challenge.leaderboard.length > 0) {
   255→    const winner = challenge.leaderboard[0];
   256→
   257→    // For "first to reach" challenges, check if target was reached
   258→    if (challenge.metric_goal === 'first' && challenge.target_value) {
   259→      if (winner.current_score >= challenge.target_value) {
   260→        winnerId = winner.user_id;
   261→      }
   262→    } else if (challenge.metric_goal === 'threshold' && challenge.target_value) {
   263→      // For threshold, winner is first to reach it
   264→      const reached = challenge.leaderboard.find(p => p.current_score >= challenge.target_value);
   265→      if (reached) {
   266→        winnerId = reached.user_id;
   267→      }
   268→    } else {
   269→      // "most" - highest score wins
   270→      winnerId = winner.user_id;
   271→    }
   272→  }
   273→
   274→  // Update challenge as completed
   275→  const { data: completedChallenge, error } = await supabase
   276→    .from('manager_challenges')
   277→    .update({
   278→      status: 'completed',
   279→      winner_id: winnerId
   280→    })
   281→    .eq('id', challengeId)
   282→    .select()
   283→    .single();
   284→
   285→  if (error) throw error;
   286→
   287→  // Award XP if applicable
   288→  if (winnerId && challenge.reward_type === 'xp_multiplier') {
   289→    const xpBonus = 100 * (challenge.reward_value || 2);
   290→    await supabase.rpc('add_user_xp', {
   291→      p_user_id: winnerId,
   292→      p_xp_amount: xpBonus
   293→    }).catch(() => {});
   294→
   295→    // Create celebration
   296→    const { createCelebrationEvent } = require('./duelsService');
   297→    await createCelebrationEvent('challenge_win', winnerId, xpBonus, {
   298→      challenge_title: challenge.title,
   299→      reward: challenge.reward_description
   300→    });
   301→  }
   302→
   303→  return completedChallenge;
   304→}
   305→
   306→/**
   307→ * Cancel a challenge (manager only)
   308→ */
   309→async function cancelChallenge(challengeId, userId) {
   310→  const { data: challenge, error: fetchError } = await supabase
   311→    .from('manager_challenges')
   312→    .select('created_by')
   313→    .eq('id', challengeId)
   314→    .single();
   315→
   316→  if (fetchError || !challenge) {
   317→    throw new Error('Challenge not found');
   318→  }
   319→
   320→  if (challenge.created_by !== userId) {
   321→    throw new Error('Only the creator can cancel this challenge');
   322→  }
   323→
   324→  const { error } = await supabase
   325→    .from('manager_challenges')
   326→    .update({ status: 'cancelled' })
   327→    .eq('id', challengeId);
   328→
   329→  if (error) throw error;
   330→
   331→  return { success: true };
   332→}
   333→
   334→/**
   335→ * Process expired challenges (run periodically)
   336→ */
   337→async function processExpiredChallenges() {
   338→  const { data: expiredChallenges } = await supabase
   339→    .from('manager_challenges')
   340→    .select('id')
   341→    .eq('status', 'active')
   342→    .lt('ends_at', new Date().toISOString());
   343→
   344→  if (expiredChallenges && expiredChallenges.length > 0) {
   345→    for (const challenge of expiredChallenges) {
   346→      try {
   347→        await completeChallenge(challenge.id);
   348→      } catch (err) {
   349→        console.error(`Error completing challenge ${challenge.id}:`, err);
   350→      }
   351→    }
   352→  }
   353→}
   354→
   355→module.exports = {
   356→  CHALLENGE_METRICS,
   357→  REWARD_TYPES,
   358→  createChallenge,
   359→  joinChallenge,
   360→  getActiveChallenges,
   361→  getChallengeDetails,
   362→  updateChallengeScores,
   363→  completeChallenge,
   364→  cancelChallenge,
   365→  processExpiredChallenges
   366→};
   367→

<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→ * AI Call Scoring Service
     3→ * Uses GPT-4 to score calls on 6 dimensions
     4→ */
     5→
     6→const { supabase, supabaseAdmin } = require('../config/database');
     7→
     8→// Use admin client for tables with RLS
     9→const db = supabaseAdmin || supabase;
    10→
    11→// Optional: Import OpenAI if available
    12→let openai = null;
    13→try {
    14→  const OpenAI = require('openai');
    15→  if (process.env.OPENAI_API_KEY) {
    16→    openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
    17→  }
    18→} catch (err) {
    19→  console.log('OpenAI not available for call scoring');
    20→}
    21→
    22→// Dimension weights
    23→const DIMENSION_WEIGHTS = {
    24→  rapport: 0.15,
    25→  discovery: 0.20,
    26→  value_prop: 0.20,
    27→  objection: 0.20,
    28→  close: 0.15,
    29→  professionalism: 0.10
    30→};
    31→
    32→// Grade thresholds
    33→const GRADE_THRESHOLDS = [
    34→  { min: 9.5, grade: 'A+' },
    35→  { min: 8.5, grade: 'A' },
    36→  { min: 7.5, grade: 'B+' },
    37→  { min: 6.5, grade: 'B' },
    38→  { min: 5.5, grade: 'C+' },
    39→  { min: 4.5, grade: 'C' },
    40→  { min: 3.0, grade: 'D' },
    41→  { min: 0, grade: 'F' }
    42→];
    43→
    44→/**
    45→ * Score a call using GPT-4
    46→ */
    47→async function scoreCall(data) {
    48→  const {
    49→    communicationId,
    50→    leadId,
    51→    userId,
    52→    transcript,
    53→    leadName = 'the lead',
    54→    trade = '',
    55→    durationSeconds = 0,
    56→    outcome = ''
    57→  } = data;
    58→
    59→  if (!transcript || transcript.length < 50) {
    60→    return { success: false, error: 'Transcript too short to score' };
    61→  }
    62→
    63→  try {
    64→    let scores;
    65→
    66→    if (openai) {
    67→      // Use GPT-4 for scoring
    68→      scores = await scoreWithGPT(transcript, { leadName, trade, durationSeconds, outcome });
    69→    } else {
    70→      // Fallback to heuristic scoring
    71→      scores = scoreWithHeuristics(transcript);
    72→    }
    73→
    74→    if (!scores) {
    75→      return { success: false, error: 'Failed to generate scores' };
    76→    }
    77→
    78→    // Calculate overall score (weighted average)
    79→    const overallScore = calculateOverallScore(scores);
    80→    const grade = getGrade(overallScore);
    81→
    82→    // Build the record
    83→    const scoreRecord = {
    84→      communication_id: communicationId,
    85→      lead_id: leadId,
    86→      user_id: userId,
    87→      overall_score: overallScore,
    88→      grade,
    89→      rapport_score: scores.rapport?.score || null,
    90→      discovery_score: scores.discovery?.score || null,
    91→      value_prop_score: scores.value_prop?.score || null,
    92→      objection_score: scores.objection?.score || null,
    93→      close_score: scores.close?.score || null,
    94→      professionalism_score: scores.professionalism?.score || null,
    95→      dimension_notes: scores,
    96→      highlights: scores.highlights || [],
    97→      improvements: scores.improvements || [],
    98→      coaching_tip: scores.coaching_tip || null,
    99→      transcript_length: transcript.length,
   100→      call_duration_seconds: durationSeconds,
   101→      had_objection: transcript.toLowerCase().includes('expensive') ||
   102→                     transcript.toLowerCase().includes('competitor') ||
   103→                     transcript.toLowerCase().includes('not interested'),
   104→      outcome
   105→    };
   106→
   107→    // Upsert score
   108→    const { data: score, error } = await db
   109→      .from('call_scores')
   110→      .upsert(scoreRecord, { onConflict: 'communication_id' })
   111→      .select()
   112→      .single();
   113→
   114→    if (error) {
   115→      console.error('Error saving call score:', error);
   116→      return { success: false, error: error.message };
   117→    }
   118→
   119→    console.log(`Call scored: ${communicationId} - ${grade} (${overallScore})`);
   120→    return { success: true, score };
   121→  } catch (error) {
   122→    console.error('Error in scoreCall:', error);
   123→    return { success: false, error: error.message };
   124→  }
   125→}
   126→
   127→/**
   128→ * Score using GPT-4
   129→ */
   130→async function scoreWithGPT(transcript, context) {
   131→  const { leadName, trade, durationSeconds, outcome } = context;
   132→  const durationMins = Math.round(durationSeconds / 60);
   133→
   134→  const prompt = `You are an expert sales call analyst. Score this call transcript on a 1-10 scale for each dimension.
   135→
   136→TRANSCRIPT:
   137→${transcript.substring(0, 8000)}
   138→
   139→CONTEXT:
   140→- Lead: ${leadName}${trade ? `, ${trade}` : ''}
   141→- Call Duration: ${durationMins} minutes
   142→- Outcome: ${outcome || 'Unknown'}
   143→
   144→Score each dimension (1-10) with brief notes:
   145→
   146→1. RAPPORT (15% weight): Did rep build personal connection? Listen actively? Show genuine interest?
   147→2. DISCOVERY (20% weight): Did rep ask good questions? Uncover needs/pain points? Understand the situation?
   148→3. VALUE PROP (20% weight): Did rep explain benefits clearly? Differentiate from competitors? Match value to needs?
   149→4. OBJECTION HANDLING (20% weight): How well did rep address concerns? Stay calm? Provide evidence?
   150→5. CLOSE (15% weight): Did rep ask for commitment? Set clear next steps? Create urgency appropriately?
   151→6. PROFESSIONALISM (10% weight): Tone, confidence, language quality? Did they represent the company well?
   152→
   153→Also provide:
   154→- 2-3 highlights (what went well)
   155→- 2-3 improvements (what could be better)
   156→- 1 coaching tip (most important thing to work on)
   157→
   158→Respond in this exact JSON format:
   159→{
   160→  "rapport": {"score": 8, "notes": "brief note"},
   161→  "discovery": {"score": 7, "notes": "brief note"},
   162→  "value_prop": {"score": 6, "notes": "brief note"},
   163→  "objection": {"score": 8, "notes": "brief note"},
   164→  "close": {"score": 5, "notes": "brief note"},
   165→  "professionalism": {"score": 9, "notes": "brief note"},
   166→  "highlights": ["highlight 1", "highlight 2"],
   167→  "improvements": ["improvement 1", "improvement 2"],
   168→  "coaching_tip": "one actionable tip"
   169→}`;
   170→
   171→  try {
   172→    const response = await openai.chat.completions.create({
   173→      model: 'gpt-4o',
   174→      messages: [
   175→        { role: 'system', content: 'You are a sales call scoring expert. Always respond with valid JSON.' },
   176→        { role: 'user', content: prompt }
   177→      ],
   178→      temperature: 0.3,
   179→      max_tokens: 1000
   180→    });
   181→
   182→    const content = response.choices[0]?.message?.content;
   183→    if (!content) {
   184→      throw new Error('No response from GPT-4');
   185→    }
   186→
   187→    // Parse JSON from response
   188→    const jsonMatch = content.match(/\{[\s\S]*\}/);
   189→    if (!jsonMatch) {
   190→      throw new Error('No JSON found in response');
   191→    }
   192→
   193→    return JSON.parse(jsonMatch[0]);
   194→  } catch (error) {
   195→    console.error('GPT-4 scoring error:', error);
   196→    // Fallback to heuristics
   197→    return scoreWithHeuristics(transcript);
   198→  }
   199→}
   200→
   201→/**
   202→ * Fallback heuristic scoring (when GPT not available)
   203→ */
   204→function scoreWithHeuristics(transcript) {
   205→  const text = transcript.toLowerCase();
   206→  const sentences = transcript.split(/[.!?]+/).filter(s => s.trim());
   207→  const words = transcript.split(/\s+/);
   208→
   209→  // Rapport indicators
   210→  const rapportKeywords = ['how are you', 'good morning', 'great to hear', 'appreciate', 'thanks for'];
   211→  const rapportScore = Math.min(10, 5 + rapportKeywords.filter(k => text.includes(k)).length * 1.5);
   212→
   213→  // Discovery indicators (questions)
   214→  const questions = transcript.match(/\?/g) || [];
   215→  const discoveryScore = Math.min(10, 3 + questions.length * 0.8);
   216→
   217→  // Value prop indicators
   218→  const valueKeywords = ['save', 'exclusive', 'benefit', 'quality', 'guarantee', 'value'];
   219→  const valueScore = Math.min(10, 4 + valueKeywords.filter(k => text.includes(k)).length * 1.5);
   220→
   221→  // Objection handling
   222→  const objectionResponses = ['understand', 'great point', 'let me explain', 'actually'];
   223→  const objectionScore = text.includes('expensive') || text.includes('competitor')
   224→    ? Math.min(10, 4 + objectionResponses.filter(k => text.includes(k)).length * 2)
   225→    : 7; // Default if no objections detected
   226→
   227→  // Close indicators
   228→  const closeKeywords = ['schedule', 'next step', 'when can', 'let\'s book', 'move forward'];
   229→  const closeScore = Math.min(10, 3 + closeKeywords.filter(k => text.includes(k)).length * 2);
   230→
   231→  // Professionalism (no filler words, good length)
   232→  const fillerCount = (text.match(/\b(um|uh|like|you know|basically)\b/g) || []).length;
   233→  const profScore = Math.max(3, 9 - fillerCount * 0.5);
   234→
   235→  return {
   236→    rapport: { score: Math.round(rapportScore * 10) / 10, notes: 'Heuristic: based on greeting keywords' },
   237→    discovery: { score: Math.round(discoveryScore * 10) / 10, notes: `Heuristic: ${questions.length} questions detected` },
   238→    value_prop: { score: Math.round(valueScore * 10) / 10, notes: 'Heuristic: based on value keywords' },
   239→    objection: { score: Math.round(objectionScore * 10) / 10, notes: 'Heuristic: based on response patterns' },
   240→    close: { score: Math.round(closeScore * 10) / 10, notes: 'Heuristic: based on close keywords' },
   241→    professionalism: { score: Math.round(profScore * 10) / 10, notes: `Heuristic: ${fillerCount} filler words` },
   242→    highlights: ['Call completed successfully'],
   243→    improvements: ['Consider using AI scoring for detailed analysis'],
   244→    coaching_tip: 'Enable OpenAI integration for comprehensive call scoring'
   245→  };
   246→}
   247→
   248→/**
   249→ * Calculate weighted overall score
   250→ */
   251→function calculateOverallScore(scores) {
   252→  let total = 0;
   253→  let weightSum = 0;
   254→
   255→  Object.entries(DIMENSION_WEIGHTS).forEach(([dim, weight]) => {
   256→    const score = scores[dim]?.score;
   257→    if (score !== undefined && score !== null) {
   258→      total += score * weight;
   259→      weightSum += weight;
   260→    }
   261→  });
   262→
   263→  if (weightSum === 0) return 5; // Default middle score
   264→
   265→  const overall = total / weightSum;
   266→  return Math.round(overall * 10) / 10;
   267→}
   268→
   269→/**
   270→ * Get letter grade from score
   271→ */
   272→function getGrade(score) {
   273→  for (const threshold of GRADE_THRESHOLDS) {
   274→    if (score >= threshold.min) {
   275→      return threshold.grade;
   276→    }
   277→  }
   278→  return 'F';
   279→}
   280→
   281→/**
   282→ * Get call score for a communication
   283→ */
   284→async function getCallScore(communicationId) {
   285→  const { data, error } = await db
   286→    .from('call_scores')
   287→    .select('*')
   288→    .eq('communication_id', communicationId)
   289→    .single();
   290→
   291→  if (error && error.code !== 'PGRST116') {
   292→    console.error('Error fetching call score:', error);
   293→    return null;
   294→  }
   295→
   296→  return data;
   297→}
   298→
   299→/**
   300→ * Get all scores for a user
   301→ */
   302→async function getUserScores(userId, options = {}) {
   303→  const { limit = 20, startDate, endDate } = options;
   304→
   305→  let query = db
   306→    .from('call_scores')
   307→    .select('*')
   308→    .eq('user_id', userId)
   309→    .order('scored_at', { ascending: false })
   310→    .limit(limit);
   311→
   312→  if (startDate) query = query.gte('scored_at', startDate);
   313→  if (endDate) query = query.lte('scored_at', endDate);
   314→
   315→  const { data, error } = await query;
   316→
   317→  if (error) {
   318→    console.error('Error fetching user scores:', error);
   319→    return [];
   320→  }
   321→
   322→  return data || [];
   323→}
   324→
   325→/**
   326→ * Get best calls (for learning/sharing)
   327→ */
   328→async function getBestCalls(options = {}) {
   329→  const { limit = 10, days = 30 } = options;
   330→
   331→  const startDate = new Date();
   332→  startDate.setDate(startDate.getDate() - days);
   333→
   334→  const { data, error } = await db
   335→    .from('call_scores')
   336→    .select('*')
   337→    .gte('scored_at', startDate.toISOString())
   338→    .order('overall_score', { ascending: false })
   339→    .limit(limit);
   340→
   341→  if (error) {
   342→    console.error('Error fetching best calls:', error);
   343→    return [];
   344→  }
   345→
   346→  return data || [];
   347→}
   348→
   349→/**
   350→ * Get calls needing coaching (low scores)
   351→ */
   352→async function getCallsNeedingCoaching(options = {}) {
   353→  const { limit = 10, threshold = 5 } = options;
   354→
   355→  const startDate = new Date();
   356→  startDate.setDate(startDate.getDate() - 7);
   357→
   358→  const { data, error } = await db
   359→    .from('call_scores')
   360→    .select('*')
   361→    .gte('scored_at', startDate.toISOString())
   362→    .lt('overall_score', threshold)
   363→    .order('overall_score', { ascending: true })
   364→    .limit(limit);
   365→
   366→  if (error) {
   367→    console.error('Error fetching calls needing coaching:', error);
   368→    return [];
   369→  }
   370→
   371→  return data || [];
   372→}
   373→
   374→/**
   375→ * Get user score statistics
   376→ */
   377→async function getUserScoreStats(userId) {
   378→  const { data, error } = await db
   379→    .from('user_call_score_summary')
   380→    .select('*')
   381→    .eq('user_id', userId)
   382→    .single();
   383→
   384→  if (error && error.code !== 'PGRST116') {
   385→    console.error('Error fetching user score stats:', error);
   386→    return null;
   387→  }
   388→
   389→  return data;
   390→}
   391→
   392→/**
   393→ * Get team score leaderboard
   394→ */
   395→async function getScoreLeaderboard(days = 7) {
   396→  const startDate = new Date();
   397→  startDate.setDate(startDate.getDate() - days);
   398→
   399→  const { data, error } = await db
   400→    .from('call_scores')
   401→    .select('user_id, overall_score, grade')
   402→    .gte('scored_at', startDate.toISOString());
   403→
   404→  if (error) {
   405→    console.error('Error fetching leaderboard:', error);
   406→    return [];
   407→  }
   408→
   409→  // Group by user
   410→  const byUser = {};
   411→  (data || []).forEach(row => {
   412→    if (!byUser[row.user_id]) {
   413→      byUser[row.user_id] = { scores: [], grades: {} };
   414→    }
   415→    byUser[row.user_id].scores.push(row.overall_score);
   416→    byUser[row.user_id].grades[row.grade] = (byUser[row.user_id].grades[row.grade] || 0) + 1;
   417→  });
   418→
   419→  // Calculate averages
   420→  const leaderboard = Object.entries(byUser).map(([userId, data]) => ({
   421→    userId,
   422→    calls: data.scores.length,
   423→    avgScore: parseFloat((data.scores.reduce((a, b) => a + b, 0) / data.scores.length).toFixed(1)),
   424→    bestScore: Math.max(...data.scores),
   425→    aGrades: (data.grades['A+'] || 0) + (data.grades['A'] || 0),
   426→    fGrades: data.grades['F'] || 0
   427→  }));
   428→
   429→  return leaderboard.sort((a, b) => b.avgScore - a.avgScore);
   430→}
   431→
   432→module.exports = {
   433→  scoreCall,
   434→  getCallScore,
   435→  getUserScores,
   436→  getBestCalls,
   437→  getCallsNeedingCoaching,
   438→  getUserScoreStats,
   439→  getScoreLeaderboard
   440→};
   441→

<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→ * Activation AI Service
     3→ * AI-powered analysis for funnel blockers and personalized nudge generation
     4→ */
     5→
     6→const OpenAI = require('openai');
     7→const logger = require('../utils/logger');
     8→const { getStageLabel, WORKER_STAGES, CONTRACTOR_STAGES } = require('./stageCalculator');
     9→
    10→// Initialize OpenAI client
    11→let openai = null;
    12→if (process.env.OPENAI_API_KEY) {
    13→  openai = new OpenAI({
    14→    apiKey: process.env.OPENAI_API_KEY,
    15→  });
    16→}
    17→
    18→// Models
    19→const MODELS = {
    20→  REASONING: 'gpt-4o',
    21→  SIMPLE: 'gpt-4o-mini',
    22→};
    23→
    24→/**
    25→ * Analyze funnel blockers and generate insights
    26→ * @param {object} funnelData - Funnel data with stages and counts
    27→ * @returns {object} - Analysis with blockers and recommendations
    28→ */
    29→async function analyzeBlockers(funnelData) {
    30→  if (!openai) {
    31→    return {
    32→      success: false,
    33→      error: 'AI features not configured - OPENAI_API_KEY not set',
    34→      fallback: generateFallbackBlockerAnalysis(funnelData),
    35→    };
    36→  }
    37→
    38→  const prompt = `Analyze this activation funnel data for RateRight (a worker marketplace connecting contractors with tradies).
    39→
    40→FUNNEL DATA:
    41→Worker Funnel:
    42→${funnelData.worker?.stages?.map(s => `- ${s.label}: ${s.count} (${s.pct}%)`).join('\n') || 'No data'}
    43→Biggest Worker Dropoff: ${funnelData.worker?.biggestDropoff ? `${funnelData.worker.biggestDropoff.fromLabel} → ${funnelData.worker.biggestDropoff.toLabel} (${funnelData.worker.biggestDropoff.dropPct}% drop)` : 'None'}
    44→
    45→Contractor Funnel:
    46→${funnelData.contractor?.stages?.map(s => `- ${s.label}: ${s.count} (${s.pct}%)`).join('\n') || 'No data'}
    47→Biggest Contractor Dropoff: ${funnelData.contractor?.biggestDropoff ? `${funnelData.contractor.biggestDropoff.fromLabel} → ${funnelData.contractor.biggestDropoff.toLabel} (${funnelData.contractor.biggestDropoff.dropPct}% drop)` : 'None'}
    48→
    49→STAGE MEANINGS:
    50→Workers: not_signed_up → signed_up → profile_complete → job_ready (has insurance/white card) → applied → working → proven
    51→Contractors: not_signed_up → signed_up → profile_complete → stripe_ready (payments set up) → job_posted → hired → active
    52→
    53→Analyze the funnel and identify blockers. Respond with JSON:
    54→{
    55→  "summary": "One sentence summary of funnel health",
    56→  "primaryBlocker": {
    57→    "stage": "The stage where most people are stuck",
    58→    "type": "worker|contractor",
    59→    "dropPct": 40,
    60→    "likelyReason": "Why people are stuck here",
    61→    "impact": "Revenue or user impact"
    62→  },
    63→  "secondaryBlockers": [
    64→    {"stage": "Another blocker", "type": "worker|contractor", "reason": "Why"}
    65→  ],
    66→  "recommendations": [
    67→    {
    68→      "priority": 1,
    69→      "action": "Specific action to take",
    70→      "expectedImpact": "What improvement to expect",
    71→      "targetStage": "Which stage this fixes"
    72→    }
    73→  ],
    74→  "healthScore": 0-100 (overall funnel health)
    75→}`;
    76→
    77→  try {
    78→    const response = await openai.chat.completions.create({
    79→      model: MODELS.REASONING,
    80→      messages: [{ role: 'user', content: prompt }],
    81→      response_format: { type: 'json_object' },
    82→      temperature: 0.6,
    83→    });
    84→
    85→    const analysis = JSON.parse(response.choices[0].message.content);
    86→    return { success: true, analysis };
    87→  } catch (error) {
    88→    logger.error('Blocker analysis error', { error: error.message });
    89→    return {
    90→      success: false,
    91→      error: error.message,
    92→      fallback: generateFallbackBlockerAnalysis(funnelData),
    93→    };
    94→  }
    95→}
    96→
    97→/**
    98→ * Generate personalized nudge message for a stuck user
    99→ * @param {object} lead - Lead data
   100→ * @param {string} stage - Current stage
   101→ * @param {number} daysStuck - Days stuck at this stage
   102→ * @param {string} diagnosis - 'forgot' | 'avoiding' | 'unknown'
   103→ * @returns {object} - Nudge message and metadata
   104→ */
   105→async function generateNudge(lead, stage, daysStuck, diagnosis) {
   106→  if (!openai) {
   107→    return {
   108→      success: false,
   109→      error: 'AI features not configured',
   110→      fallback: generateFallbackNudge(lead, stage, diagnosis),
   111→    };
   112→  }
   113→
   114→  const firstName = lead.first_name || lead.name?.split(' ')[0] || 'there';
   115→  const leadType = lead.type || lead.metadata?.lead_type || 'unknown';
   116→  const company = lead.company || '';
   117→
   118→  // Get next step based on stage
   119→  const nextStep = getNextStepForStage(stage, leadType);
   120→
   121→  const prompt = `Generate a personalized SMS nudge for a stuck user on RateRight (worker marketplace).
   122→
   123→USER:
   124→- Name: ${firstName}
   125→- Company: ${company || 'Not set'}
   126→- Type: ${leadType}
   127→- Current Stage: ${getStageLabel(stage)}
   128→- Days Stuck: ${daysStuck}
   129→- Behavior: ${diagnosis === 'forgot' ? 'Hasn\'t logged in (probably forgot about us)' : diagnosis === 'avoiding' ? 'Logging in but not progressing (avoiding next step)' : 'Unknown behavior pattern'}
   130→
   131→NEXT STEP THEY NEED TO TAKE:
   132→${nextStep}
   133→
   134→REQUIREMENTS:
   135→- Max 160 characters (ideally under 120)
   136→- Friendly, not salesy
   137→- Reference their specific situation
   138→- Include a clear call-to-action
   139→- Australian casual tone (but professional)
   140→- Don't be pushy - help them, don't pressure them
   141→
   142→TONE GUIDE:
   143→- "forgot": Light reminder, assume they've been busy
   144→- "avoiding": Address the friction, offer help
   145→- "unknown": Friendly check-in
   146→
   147→Respond with JSON:
   148→{
   149→  "message": "The SMS to send",
   150→  "tone": "friendly|urgent|helpful",
   151→  "charCount": 123,
   152→  "callToAction": "What you're asking them to do",
   153→  "expectedResponse": "What kind of reply to expect"
   154→}`;
   155→
   156→  try {
   157→    const response = await openai.chat.completions.create({
   158→      model: MODELS.SIMPLE,
   159→      messages: [{ role: 'user', content: prompt }],
   160→      response_format: { type: 'json_object' },
   161→      temperature: 0.8,
   162→    });
   163→
   164→    const nudge = JSON.parse(response.choices[0].message.content);
   165→    return { success: true, nudge };
   166→  } catch (error) {
   167→    logger.error('Nudge generation error', { error: error.message, leadId: lead.id });
   168→    return {
   169→      success: false,
   170→      error: error.message,
   171→      fallback: generateFallbackNudge(lead, stage, diagnosis),
   172→    };
   173→  }
   174→}
   175→
   176→/**
   177→ * Generate bulk nudges for multiple users at a stage
   178→ * @param {array} leads - Array of stuck leads
   179→ * @param {string} stage - The stage they're stuck at
   180→ * @returns {array} - Array of nudges
   181→ */
   182→async function generateBulkNudges(leads, stage) {
   183→  const nudges = [];
   184→
   185→  for (const lead of leads) {
   186→    const result = await generateNudge(
   187→      lead,
   188→      lead.stage || stage,
   189→      lead.daysStuck || 7,
   190→      lead.diagnosis || 'unknown'
   191→    );
   192→
   193→    nudges.push({
   194→      leadId: lead.id,
   195→      name: [lead.first_name, lead.last_name].filter(Boolean).join(' ') || 'Unknown',
   196→      email: lead.email,
   197→      phone: lead.phone,
   198→      stage: lead.stage || stage,
   199→      nudge: result.success ? result.nudge : result.fallback,
   200→      aiGenerated: result.success,
   201→    });
   202→  }
   203→
   204→  return nudges;
   205→}
   206→
   207→/**
   208→ * Suggest action to fix a blocker
   209→ * @param {string} stage - The blocked stage
   210→ * @param {string} leadType - 'worker' | 'contractor'
   211→ * @returns {object} - Suggested actions
   212→ */
   213→async function suggestAction(stage, leadType) {
   214→  if (!openai) {
   215→    return {
   216→      success: false,
   217→      error: 'AI not configured',
   218→      fallback: getFallbackAction(stage, leadType),
   219→    };
   220→  }
   221→
   222→  const prompt = `A user on RateRight (worker marketplace) is stuck at the "${getStageLabel(stage)}" stage.
   223→
   224→USER TYPE: ${leadType}
   225→STAGE: ${stage}
   226→
   227→STAGE CONTEXT:
   228→${getStageContext(stage, leadType)}
   229→
   230→Generate a call script/talking points for our sales team to help move this user forward.
   231→
   232→Respond with JSON:
   233→{
   234→  "objective": "What we're trying to achieve on this call",
   235→  "openingLine": "Natural way to start the conversation",
   236→  "keyQuestions": [
   237→    "Question to understand their blocker"
   238→  ],
   239→  "commonObjections": [
   240→    {
   241→      "objection": "What they might say",
   242→      "response": "How to handle it"
   243→    }
   244→  ],
   245→  "nextStepToOffer": "Specific next step to guide them to",
   246→  "closingLine": "How to end the call with commitment"
   247→}`;
   248→
   249→  try {
   250→    const response = await openai.chat.completions.create({
   251→      model: MODELS.REASONING,
   252→      messages: [{ role: 'user', content: prompt }],
   253→      response_format: { type: 'json_object' },
   254→      temperature: 0.7,
   255→    });
   256→
   257→    const script = JSON.parse(response.choices[0].message.content);
   258→    return { success: true, script };
   259→  } catch (error) {
   260→    logger.error('Action suggestion error', { error: error.message });
   261→    return {
   262→      success: false,
   263→      error: error.message,
   264→      fallback: getFallbackAction(stage, leadType),
   265→    };
   266→  }
   267→}
   268→
   269→/**
   270→ * Analyze why users drop off at a specific stage
   271→ */
   272→async function analyzeStageDropoff(stage, leadType, sampleLeads = []) {
   273→  if (!openai) {
   274→    return {
   275→      success: false,
   276→      error: 'AI not configured',
   277→    };
   278→  }
   279→
   280→  const leadSamples = sampleLeads.slice(0, 5).map(l => ({
   281→    daysStuck: l.daysStuck,
   282→    lastLogin: l.daysSinceLogin,
   283→    healthScore: l.healthScore,
   284→    diagnosis: l.diagnosis,
   285→  }));
   286→
   287→  const prompt = `Analyze why ${leadType}s are dropping off at the "${getStageLabel(stage)}" stage on RateRight.
   288→
   289→SAMPLE DATA (${sampleLeads.length} stuck users):
   290→${JSON.stringify(leadSamples, null, 2)}
   291→
   292→What they need to do to progress:
   293→${getNextStepForStage(stage, leadType)}
   294→
   295→Analyze patterns and suggest:
   296→1. Root cause of the dropoff
   297→2. What might be confusing or hard
   298→3. How to fix it (product changes or outreach)
   299→
   300→Respond with JSON:
   301→{
   302→  "rootCause": "Most likely reason for the dropoff",
   303→  "patterns": [
   304→    {"pattern": "Observable pattern", "percentage": 60}
   305→  ],
   306→  "userExperience": "What the user is probably experiencing",
   307→  "fixes": [
   308→    {
   309→      "type": "product|process|outreach",
   310→      "suggestion": "What to change",
   311→      "effort": "low|medium|high",
   312→      "impact": "low|medium|high"
   313→    }
   314→  ]
   315→}`;
   316→
   317→  try {
   318→    const response = await openai.chat.completions.create({
   319→      model: MODELS.REASONING,
   320→      messages: [{ role: 'user', content: prompt }],
   321→      response_format: { type: 'json_object' },
   322→      temperature: 0.6,
   323→    });
   324→
   325→    const analysis = JSON.parse(response.choices[0].message.content);
   326→    return { success: true, analysis };
   327→  } catch (error) {
   328→    logger.error('Stage dropoff analysis error', { error: error.message });
   329→    return { success: false, error: error.message };
   330→  }
   331→}
   332→
   333→// ============================================================
   334→// Helper Functions
   335→// ============================================================
   336→
   337→function getNextStepForStage(stage, leadType) {
   338→  const workerSteps = {
   339→    signed_up: 'Complete their profile by adding their primary trade',
   340→    profile_complete: 'Upload insurance documents or white card to become job-ready',
   341→    job_ready: 'Apply to their first job',
   342→    applied: 'Get accepted and start working',
   343→    working: 'Complete their first job and get a review',
   344→  };
   345→
   346→  const contractorSteps = {
   347→    signed_up: 'Add their business name and ABN',
   348→    profile_complete: 'Connect their Stripe account for payments',
   349→    stripe_ready: 'Post their first job',
   350→    job_posted: 'Accept a worker for their job',
   351→    hired: 'Complete a job and leave a review',
   352→  };
   353→
   354→  return leadType === 'worker'
   355→    ? workerSteps[stage] || 'Progress to the next stage'
   356→    : contractorSteps[stage] || 'Progress to the next stage';
   357→}
   358→
   359→function getStageContext(stage, leadType) {
   360→  const contexts = {
   361→    // Worker stages
   362→    signed_up: 'They created an account but haven\'t filled out their profile yet.',
   363→    profile_complete: 'They added their trade but don\'t have insurance/white card uploaded.',
   364→    job_ready: 'They have all documents but haven\'t applied to any jobs yet.',
   365→    applied: 'They\'ve applied to jobs but haven\'t been accepted yet.',
   366→    working: 'They\'re working but haven\'t completed a job yet.',
   367→    // Contractor stages
   368→    stripe_ready: 'They set up payments but haven\'t posted a job yet.',
   369→    job_posted: 'They posted a job but haven\'t hired anyone.',
   370→    hired: 'They hired someone but the job isn\'t complete yet.',
   371→  };
   372→
   373→  return contexts[stage] || 'Standard activation stage';
   374→}
   375→
   376→function generateFallbackNudge(lead, stage, diagnosis) {
   377→  const firstName = lead.first_name || lead.name?.split(' ')[0] || 'there';
   378→
   379→  const templates = {
   380→    forgot: {
   381→      signed_up: `Hey ${firstName}! Still keen to get set up? Only takes 2 mins to complete your profile.`,
   382→      profile_complete: `Hi ${firstName}, you're almost there! Just need to add your docs to start.`,
   383→      job_ready: `${firstName}, jobs are waiting! Ready to apply to your first one?`,
   384→      stripe_ready: `Hey ${firstName}, payments sorted - time to post your first job?`,
   385→      job_posted: `Hi ${firstName}, your job's up! Need help finding the right worker?`,
   386→    },
   387→    avoiding: {
   388→      signed_up: `Hi ${firstName}, stuck on something? Happy to help you get set up.`,
   389→      profile_complete: `Hey ${firstName}, any questions about the docs? We can walk you through.`,
   390→      job_ready: `${firstName}, not sure which jobs to go for? Let us know what you're looking for.`,
   391→      stripe_ready: `Hi ${firstName}, Stripe giving you trouble? Common snag - we can help.`,
   392→      job_posted: `Hey ${firstName}, need help picking from applicants? We've got tips.`,
   393→    },
   394→    unknown: {
   395→      signed_up: `Hey ${firstName}! How can we help you get started?`,
   396→      profile_complete: `Hi ${firstName}, just checking in. Anything we can help with?`,
   397→      job_ready: `${firstName}, all set up! Ready to find work?`,
   398→      stripe_ready: `Hey ${firstName}, you're all set! Time to post a job?`,
   399→      job_posted: `Hi ${firstName}, how's it going? Need any help?`,
   400→    },
   401→  };
   402→
   403→  const diagnosisTemplates = templates[diagnosis] || templates.unknown;
   404→  return {
   405→    message: diagnosisTemplates[stage] || `Hi ${firstName}! Just checking in - how can we help?`,
   406→    tone: diagnosis === 'forgot' ? 'friendly' : diagnosis === 'avoiding' ? 'helpful' : 'friendly',
   407→    charCount: 100,
   408→    callToAction: 'Reply with any questions',
   409→    expectedResponse: 'Question or confirmation',
   410→  };
   411→}
   412→
   413→function generateFallbackBlockerAnalysis(funnelData) {
   414→  const workerDropoff = funnelData.worker?.biggestDropoff;
   415→  const contractorDropoff = funnelData.contractor?.biggestDropoff;
   416→
   417→  const primaryBlocker = workerDropoff?.dropPct > (contractorDropoff?.dropPct || 0)
   418→    ? { ...workerDropoff, type: 'worker' }
   419→    : contractorDropoff
   420→      ? { ...contractorDropoff, type: 'contractor' }
   421→      : null;
   422→
   423→  return {
   424→    summary: 'Funnel analysis available - AI insights require OPENAI_API_KEY',
   425→    primaryBlocker: primaryBlocker
   426→      ? {
   427→          stage: primaryBlocker.to,
   428→          type: primaryBlocker.type,
   429→          dropPct: primaryBlocker.dropPct,
   430→          likelyReason: 'Manual review required',
   431→        }
   432→      : null,
   433→    recommendations: [
   434→      {
   435→        priority: 1,
   436→        action: 'Review biggest dropoff manually',
   437→        targetStage: primaryBlocker?.to || 'unknown',
   438→      },
   439→    ],
   440→    healthScore: 50,
   441→  };
   442→}
   443→
   444→function getFallbackAction(stage, leadType) {
   445→  return {
   446→    objective: `Help ${leadType} progress past ${getStageLabel(stage)}`,
   447→    openingLine: `Hey, just checking in on your RateRight account. How's it going?`,
   448→    keyQuestions: [
   449→      'What\'s holding you back from taking the next step?',
   450→      'Is there anything confusing about the process?',
   451→    ],
   452→    commonObjections: [
   453→      {
   454→        objection: 'Too busy right now',
   455→        response: 'Totally understand - it only takes 2 minutes. Want me to walk you through?',
   456→      },
   457→    ],
   458→    nextStepToOffer: getNextStepForStage(stage, leadType),
   459→    closingLine: 'Let me know if you have any questions!',
   460→  };
   461→}
   462→
   463→module.exports = {
   464→  analyzeBlockers,
   465→  generateNudge,
   466→  generateBulkNudges,
   467→  suggestAction,
   468→  analyzeStageDropoff,
   469→};
   470→

<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→ * Challenges Service - Daily individual + team challenges
     3→ * Part of Gamification Battleground (Priority #7)
     4→ */
     5→const { supabase } = require('../config/database');
     6→const { createPulseEvent } = require('./leaderboardService');
     7→
     8→/**
     9→ * Get today's date string in AEST timezone (YYYY-MM-DD format)
    10→ * Used for challenge date tracking
    11→ */
    12→function getTodayDateAEST() {
    13→  const AEST_OFFSET_MS = 10 * 60 * 60 * 1000;
    14→  const now = new Date();
    15→  const aestTime = new Date(now.getTime() + AEST_OFFSET_MS);
    16→  return aestTime.toISOString().split('T')[0];
    17→}
    18→
    19→// Challenge templates
    20→const INDIVIDUAL_CHALLENGES = [
    21→  { title: '5 calls before lunch', target: 5, type: 'calls_before_noon', xp: 30 },
    22→  { title: 'Beat your daily average', target: 0, type: 'beat_average', xp: 40 },
    23→  { title: 'Get 2 callbacks scheduled', target: 2, type: 'callbacks', xp: 35 },
    24→  { title: 'First call before 9am', target: 1, type: 'early_call', xp: 25 },
    25→  { title: 'Close a deal today', target: 1, type: 'conversion', xp: 50 },
    26→  { title: 'Send 10 follow-up SMS', target: 10, type: 'sms_sent', xp: 25 },
    27→  { title: '3 conversations (not voicemail)', target: 3, type: 'conversations', xp: 35 },
    28→  { title: 'Use AI Copilot on 2 calls', target: 2, type: 'copilot_used', xp: 30 },
    29→];
    30→
    31→const TEAM_CHALLENGES = [
    32→  { title: 'Team 50 calls', target: 50, xp: 100, description: 'Everyone gets bonus when team hits 50 calls' },
    33→  { title: 'Team 5 wins', target: 5, xp: 150, description: '5 conversions as a team' },
    34→  { title: 'Full squad active', target: 10, xp: 75, description: 'All 10 team members make at least 1 call' },
    35→  { title: 'Zero missed callbacks', target: 0, xp: 100, description: 'No overdue callbacks by EOD' },
    36→];
    37→
    38→/**
    39→ * Generate today's challenges for a user
    40→ */
    41→async function generateDailyChallenges(userId) {
    42→  try {
    43→    const today = getTodayDateAEST();
    44→
    45→    // Check if already generated
    46→    const { data: existing } = await supabase
    47→      .from('daily_challenges')
    48→      .select('*')
    49→      .eq('date', today)
    50→      .eq('user_id', userId);
    51→
    52→    if (existing?.length > 0) {
    53→      return existing;
    54→    }
    55→
    56→    // Pick 2 random individual challenges
    57→    const shuffled = [...INDIVIDUAL_CHALLENGES].sort(() => Math.random() - 0.5);
    58→    const selected = shuffled.slice(0, 2);
    59→
    60→    // Get user's average for "beat average" challenge
    61→    const weekAgo = new Date();
    62→    weekAgo.setDate(weekAgo.getDate() - 7);
    63→
    64→    const { data: history } = await supabase
    65→      .from('call_log')
    66→      .select('id')
    67→      .eq('user_id', userId)
    68→      .gte('created_at', weekAgo.toISOString());
    69→
    70→    const avgCalls = Math.ceil((history?.length || 0) / 7);
    71→
    72→    // Create challenges
    73→    const challenges = selected.map(c => ({
    74→      date: today,
    75→      challenge_type: 'individual',
    76→      title: c.title,
    77→      description: c.description || `Complete to earn ${c.xp} XP`,
    78→      target_value: c.type === 'beat_average' ? Math.max(avgCalls + 1, 3) : c.target,
    79→      bonus_xp: c.xp,
    80→      user_id: userId,
    81→    }));
    82→
    83→    const { data: created, error } = await supabase
    84→      .from('daily_challenges')
    85→      .insert(challenges)
    86→      .select();
    87→
    88→    if (error) {
    89→      // 23505 = duplicate key - challenges already exist (race condition), just fetch them
    90→      if (error.code === '23505') {
    91→        const { data: existing } = await supabase
    92→          .from('daily_challenges')
    93→          .select('*')
    94→          .eq('date', today)
    95→          .eq('user_id', userId);
    96→        return existing || [];
    97→      }
    98→      console.error('generateDailyChallenges error:', error);
    99→      return [];
   100→    }
   101→
   102→    return created || [];
   103→  } catch (error) {
   104→    console.error('generateDailyChallenges error:', error);
   105→    return [];
   106→  }
   107→}
   108→
   109→/**
   110→ * Generate team challenge for today
   111→ */
   112→async function generateTeamChallenge() {
   113→  try {
   114→    const today = getTodayDateAEST();
   115→
   116→    const { data: existing } = await supabase
   117→      .from('daily_challenges')
   118→      .select('*')
   119→      .eq('date', today)
   120→      .eq('challenge_type', 'team')
   121→      .is('user_id', null);
   122→
   123→    if (existing?.length > 0) {
   124→      return existing[0];
   125→    }
   126→
   127→    // Pick random team challenge
   128→    const challenge = TEAM_CHALLENGES[Math.floor(Math.random() * TEAM_CHALLENGES.length)];
   129→
   130→    const { data: created, error } = await supabase
   131→      .from('daily_challenges')
   132→      .insert({
   133→        date: today,
   134→        challenge_type: 'team',
   135→        title: challenge.title,
   136→        description: challenge.description,
   137→        target_value: challenge.target,
   138→        bonus_xp: challenge.xp,
   139→        user_id: null,
   140→      })
   141→      .select()
   142→      .single();
   143→
   144→    if (error) {
   145→      // 23505 = duplicate key - team challenge already exists (race condition), just fetch it
   146→      if (error.code === '23505') {
   147→        const { data: existing } = await supabase
   148→          .from('daily_challenges')
   149→          .select('*')
   150→          .eq('date', today)
   151→          .eq('challenge_type', 'team')
   152→          .is('user_id', null)
   153→          .single();
   154→        return existing;
   155→      }
   156→      console.error('generateTeamChallenge error:', error);
   157→      return null;
   158→    }
   159→
   160→    return created;
   161→  } catch (error) {
   162→    console.error('generateTeamChallenge error:', error);
   163→    return null;
   164→  }
   165→}
   166→
   167→/**
   168→ * Update challenge progress
   169→ */
   170→async function updateChallengeProgress(userId, actionType, count = 1) {
   171→  try {
   172→    const today = getTodayDateAEST();
   173→
   174→    // Get user's challenges and team challenge
   175→    const { data: challenges } = await supabase
   176→      .from('daily_challenges')
   177→      .select('*')
   178→      .eq('date', today)
   179→      .eq('completed', false)
   180→      .or(`user_id.eq.${userId},user_id.is.null`);
   181→
   182→    const completed = [];
   183→
   184→    for (const challenge of challenges || []) {
   185→      let shouldUpdate = false;
   186→      let newValue = challenge.current_value || 0;
   187→
   188→      // Map action types to challenge types
   189→      const titleLower = challenge.title.toLowerCase();
   190→
   191→      if (actionType === 'call') {
   192→        if (titleLower.includes('call')) {
   193→          shouldUpdate = true;
   194→          newValue += count;
   195→        }
   196→        if (titleLower.includes('conversation') && !titleLower.includes('voicemail')) {
   197→          // Check if it was a real conversation (not voicemail)
   198→          shouldUpdate = true;
   199→          newValue += count;
   200→        }
   201→      }
   202→
   203→      if (actionType === 'conversion') {
   204→        if (titleLower.includes('close') || titleLower.includes('deal') || titleLower.includes('win')) {
   205→          shouldUpdate = true;
   206→          newValue += count;
   207→        }
   208→      }
   209→
   210→      if (actionType === 'callback_scheduled') {
   211→        if (titleLower.includes('callback')) {
   212→          shouldUpdate = true;
   213→          newValue += count;
   214→        }
   215→      }
   216→
   217→      if (actionType === 'sms_sent') {
   218→        if (titleLower.includes('sms') || titleLower.includes('follow-up')) {
   219→          shouldUpdate = true;
   220→          newValue += count;
   221→        }
   222→      }
   223→
   224→      if (actionType === 'copilot_used') {
   225→        if (titleLower.includes('copilot') || titleLower.includes('ai')) {
   226→          shouldUpdate = true;
   227→          newValue += count;
   228→        }
   229→      }
   230→
   231→      if (actionType === 'early_call') {
   232→        if (titleLower.includes('before 9am') || titleLower.includes('early')) {
   233→          shouldUpdate = true;
   234→          newValue += count;
   235→        }
   236→      }
   237→
   238→      if (shouldUpdate) {
   239→        const isComplete = newValue >= challenge.target_value;
   240→
   241→        const { error: updateError } = await supabase
   242→          .from('daily_challenges')
   243→          .update({
   244→            current_value: newValue,
   245→            completed: isComplete,
   246→            completed_at: isComplete ? new Date().toISOString() : null,
   247→          })
   248→          .eq('id', challenge.id);
   249→
   250→        if (updateError) {
   251→          console.error('Challenge update error:', updateError);
   252→          continue;
   253→        }
   254→
   255→        if (isComplete) {
   256→          completed.push(challenge);
   257→
   258→          // Award XP
   259→          try {
   260→            const xpService = require('./xpService');
   261→            if (xpService && xpService.awardXP) {
   262→              await xpService.awardXP(userId, 'challenge_complete', {
   263→                customAmount: challenge.bonus_xp,
   264→                reason: `Challenge: ${challenge.title}`,
   265→              });
   266→            }
   267→          } catch (xpError) {
   268→            console.error('XP award error:', xpError);
   269→          }
   270→
   271→          // Get user name for pulse event
   272→          const { data: user } = await supabase
   273→            .from('user_xp')
   274→            .select('display_name')
   275→            .eq('user_id', userId)
   276→            .single();
   277→
   278→          const userName = user?.display_name || 'Someone';
   279→
   280→          // Pulse event
   281→          await createPulseEvent({
   282→            event_type: 'challenge_complete',
   283→            user_id: userId,
   284→            user_name: userName,
   285→            title: `${userName} completed: ${challenge.title}`,
   286→            subtitle: `+${challenge.bonus_xp} XP`,
   287→            emoji: '✅',
   288→          });
   289→        }
   290→      }
   291→    }
   292→
   293→    return { updated: true, completed };
   294→  } catch (error) {
   295→    console.error('updateChallengeProgress error:', error);
   296→    return { updated: false, completed: [], error: error.message };
   297→  }
   298→}
   299→
   300→/**
   301→ * Get today's challenges for a user
   302→ */
   303→async function getTodayChallenges(userId) {
   304→  try {
   305→    const today = getTodayDateAEST();
   306→
   307→    // Ensure challenges exist
   308→    await generateDailyChallenges(userId);
   309→    await generateTeamChallenge();
   310→
   311→    const { data, error } = await supabase
   312→      .from('daily_challenges')
   313→      .select('*')
   314→      .eq('date', today)
   315→      .or(`user_id.eq.${userId},user_id.is.null`);
   316→
   317→    if (error) {
   318→      console.error('getTodayChallenges error:', error);
   319→      return { individual: [], team: null };
   320→    }
   321→
   322→    const individual = (data || []).filter(c => c.challenge_type === 'individual');
   323→    const team = (data || []).find(c => c.challenge_type === 'team');
   324→
   325→    return { individual, team };
   326→  } catch (error) {
   327→    console.error('getTodayChallenges error:', error);
   328→    return { individual: [], team: null };
   329→  }
   330→}
   331→
   332→/**
   333→ * Update team challenge progress
   334→ * Call this when any team member completes an action
   335→ */
   336→async function updateTeamChallengeProgress(actionType, count = 1) {
   337→  try {
   338→    const today = getTodayDateAEST();
   339→
   340→    const { data: teamChallenge } = await supabase
   341→      .from('daily_challenges')
   342→      .select('*')
   343→      .eq('date', today)
   344→      .eq('challenge_type', 'team')
   345→      .is('user_id', null)
   346→      .eq('completed', false)
   347→      .single();
   348→
   349→    if (!teamChallenge) return { updated: false };
   350→
   351→    const titleLower = teamChallenge.title.toLowerCase();
   352→    let shouldUpdate = false;
   353→    let newValue = teamChallenge.current_value || 0;
   354→
   355→    if (actionType === 'call' && titleLower.includes('call')) {
   356→      shouldUpdate = true;
   357→      newValue += count;
   358→    }
   359→
   360→    if (actionType === 'conversion' && titleLower.includes('win')) {
   361→      shouldUpdate = true;
   362→      newValue += count;
   363→    }
   364→
   365→    if (shouldUpdate) {
   366→      const isComplete = newValue >= teamChallenge.target_value;
   367→
   368→      await supabase
   369→        .from('daily_challenges')
   370→        .update({
   371→          current_value: newValue,
   372→          completed: isComplete,
   373→          completed_at: isComplete ? new Date().toISOString() : null,
   374→        })
   375→        .eq('id', teamChallenge.id);
   376→
   377→      if (isComplete) {
   378→        // Team challenge completed - award XP to all active users
   379→        await createPulseEvent({
   380→          event_type: 'team_challenge_complete',
   381→          user_id: 'team',
   382→          user_name: 'The Team',
   383→          title: `Team challenge completed: ${teamChallenge.title}!`,
   384→          subtitle: `Everyone gets +${teamChallenge.bonus_xp} XP`,
   385→          emoji: '🎊',
   386→        });
   387→
   388→        // Award bonus XP to all users who made calls today
   389→        const { data: activeUsers } = await supabase
   390→          .from('call_log')
   391→          .select('user_id')
   392→          .gte('created_at', new Date(today).toISOString());
   393→
   394→        const uniqueUsers = [...new Set((activeUsers || []).map(u => u.user_id))];
   395→
   396→        for (const uid of uniqueUsers) {
   397→          try {
   398→            const xpService = require('./xpService');
   399→            if (xpService && xpService.awardXP) {
   400→              await xpService.awardXP(uid, 'team_challenge_complete', {
   401→                customAmount: teamChallenge.bonus_xp,
   402→                reason: `Team Challenge: ${teamChallenge.title}`,
   403→              });
   404→            }
   405→          } catch (e) {
   406→            console.error('Team XP award error:', e);
   407→          }
   408→        }
   409→      }
   410→
   411→      return { updated: true, completed: isComplete };
   412→    }
   413→
   414→    return { updated: false };
   415→  } catch (error) {
   416→    console.error('updateTeamChallengeProgress error:', error);
   417→    return { updated: false, error: error.message };
   418→  }
   419→}
   420→
   421→module.exports = {
   422→  INDIVIDUAL_CHALLENGES,
   423→  TEAM_CHALLENGES,
   424→  generateDailyChallenges,
   425→  generateTeamChallenge,
   426→  updateChallengeProgress,
   427→  getTodayChallenges,
   428→  updateTeamChallengeProgress,
   429→};
   430→

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


Now let me read the next batch of files.


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


     1→/**
     2→ * Badges Service - Weekly badge awards
     3→ * Part of Gamification Battleground (Priority #7)
     4→ */
     5→const { supabase } = require('../config/database');
     6→const { WEEKLY_BADGES, createPulseEvent } = require('./leaderboardService');
     7→
     8→/**
     9→ * Calculate and award weekly badges (run every Friday 3pm AEST)
    10→ */
    11→async function awardWeeklyBadges() {
    12→  try {
    13→    const now = new Date();
    14→    const weekStart = getWeekStart(now);
    15→    const weekEnd = new Date(weekStart);
    16→    weekEnd.setDate(weekEnd.getDate() + 6);
    17→
    18→    // Get all user stats for the week
    19→    const { data: users, error: usersError } = await supabase
    20→      .from('user_xp')
    21→      .select('user_id, display_name');
    22→
    23→    if (usersError) {
    24→      console.error('awardWeeklyBadges users error:', usersError);
    25→      return { badges: [], error: usersError.message };
    26→    }
    27→
    28→    const stats = await Promise.all(
    29→      (users || []).map(async (user) => {
    30→        const userId = user.user_id;
    31→
    32→        // Get call stats
    33→        const { data: calls } = await supabase
    34→          .from('call_log')
    35→          .select('id, outcome, created_at')
    36→          .eq('user_id', userId)
    37→          .gte('created_at', weekStart.toISOString())
    38→          .lte('created_at', weekEnd.toISOString());
    39→
    40→        const totalCalls = calls?.length || 0;
    41→        const conversions = calls?.filter(c => c.outcome === 'converted' || c.outcome === 'won')?.length || 0;
    42→        const conversionRate = totalCalls > 0 ? (conversions / totalCalls) * 100 : 0;
    43→
    44→        // Count early/late calls
    45→        const earlyBirdCalls = calls?.filter(c => {
    46→          const hour = new Date(c.created_at).getHours();
    47→          return hour < 9;
    48→        })?.length || 0;
    49→
    50→        const nightOwlCalls = calls?.filter(c => {
    51→          const hour = new Date(c.created_at).getHours();
    52→          return hour >= 17;
    53→        })?.length || 0;
    54→
    55→        // Days active
    56→        const activeDays = new Set(
    57→          calls?.map(c => new Date(c.created_at).toDateString())
    58→        ).size;
    59→
    60→        return {
    61→          userId,
    62→          name: user.display_name || 'Anonymous',
    63→          calls: totalCalls,
    64→          conversions,
    65→          conversionRate,
    66→          earlyBirdCalls,
    67→          nightOwlCalls,
    68→          daysActive: activeDays,
    69→        };
    70→      })
    71→    );
    72→
    73→    // Award badges
    74→    const badges = [];
    75→
    76→    // Closer - Most conversions
    77→    const closerCandidates = [...stats].sort((a, b) => b.conversions - a.conversions);
    78→    const closer = closerCandidates[0];
    79→    if (closer?.conversions > 0) {
    80→      badges.push({ ...WEEKLY_BADGES.CLOSER, winner: closer });
    81→    }
    82→
    83→    // Dialer - Most calls
    84→    const dialerCandidates = [...stats].sort((a, b) => b.calls - a.calls);
    85→    const dialer = dialerCandidates[0];
    86→    if (dialer?.calls > 0) {
    87→      badges.push({ ...WEEKLY_BADGES.DIALER, winner: dialer });
    88→    }
    89→
    90→    // Sniper - Highest conversion rate (min 5 calls)
    91→    const sniperCandidates = stats
    92→      .filter(s => s.calls >= 5)
    93→      .sort((a, b) => b.conversionRate - a.conversionRate);
    94→    const sniper = sniperCandidates[0];
    95→    if (sniper) {
    96→      badges.push({ ...WEEKLY_BADGES.SNIPER, winner: sniper });
    97→    }
    98→
    99→    // Consistent - Didn't miss a day (5+ days active)
   100→    const consistentCandidates = stats.filter(s => s.daysActive >= 5);
   101→    const consistent = consistentCandidates[0];
   102→    if (consistent) {
   103→      badges.push({ ...WEEKLY_BADGES.CONSISTENT, winner: consistent });
   104→    }
   105→
   106→    // Early Bird - Most calls before 9am
   107→    const earlyBirdCandidates = [...stats].sort((a, b) => b.earlyBirdCalls - a.earlyBirdCalls);
   108→    const earlyBird = earlyBirdCandidates[0];
   109→    if (earlyBird?.earlyBirdCalls > 0) {
   110→      badges.push({ ...WEEKLY_BADGES.EARLY_BIRD, winner: earlyBird });
   111→    }
   112→
   113→    // Night Owl - Most calls after 5pm
   114→    const nightOwlCandidates = [...stats].sort((a, b) => b.nightOwlCalls - a.nightOwlCalls);
   115→    const nightOwl = nightOwlCandidates[0];
   116→    if (nightOwl?.nightOwlCalls > 0) {
   117→      badges.push({ ...WEEKLY_BADGES.NIGHT_OWL, winner: nightOwl });
   118→    }
   119→
   120→    // Speed Demon - Fastest average SMS response time
   121→    try {
   122→      const { data: responseTimes } = await supabase
   123→        .from('communications')
   124→        .select('response_time_seconds, metadata')
   125→        .eq('direction', 'outbound')
   126→        .not('response_time_seconds', 'is', null)
   127→        .gte('created_at', weekStart.toISOString())
   128→        .lte('created_at', weekEnd.toISOString());
   129→
   130→      if (responseTimes && responseTimes.length > 0) {
   131→        // Group by user (from metadata.user_id if available, or default)
   132→        const userResponseTimes = {};
   133→        for (const rt of responseTimes) {
   134→          const userId = rt.metadata?.user_id || 'default-user';
   135→          if (!userResponseTimes[userId]) {
   136→            userResponseTimes[userId] = [];
   137→          }
   138→          userResponseTimes[userId].push(rt.response_time_seconds);
   139→        }
   140→
   141→        // Calculate averages and find fastest (min 5 responses to qualify)
   142→        const avgTimes = Object.entries(userResponseTimes)
   143→          .filter(([, times]) => times.length >= 3) // Min 3 responses to qualify
   144→          .map(([userId, times]) => ({
   145→            userId,
   146→            avgResponseTime: Math.floor(times.reduce((a, b) => a + b, 0) / times.length),
   147→            responseCount: times.length
   148→          }))
   149→          .sort((a, b) => a.avgResponseTime - b.avgResponseTime);
   150→
   151→        if (avgTimes.length > 0) {
   152→          const speedDemon = avgTimes[0];
   153→          // Find user name
   154→          const userStat = stats.find(s => s.userId === speedDemon.userId);
   155→          if (userStat) {
   156→            badges.push({
   157→              ...WEEKLY_BADGES.SPEED_DEMON,
   158→              winner: {
   159→                ...userStat,
   160→                avgResponseTime: speedDemon.avgResponseTime,
   161→                responseCount: speedDemon.responseCount
   162→              }
   163→            });
   164→          }
   165→        }
   166→      }
   167→    } catch (speedDemonError) {
   168→      console.warn('Speed Demon badge calculation failed:', speedDemonError.message);
   169→    }
   170→
   171→    // Save badges to database
   172→    for (const badge of badges) {
   173→      const { error: badgeError } = await supabase
   174→        .from('weekly_badges')
   175→        .upsert({
   176→          user_id: badge.winner.userId,
   177→          badge_type: badge.id,
   178→          week_start: weekStart.toISOString().split('T')[0],
   179→          week_end: weekEnd.toISOString().split('T')[0],
   180→          value: badge.winner[badge.stat] || badge.winner.calls || 0,
   181→        });
   182→
   183→      if (badgeError) {
   184→        console.error('Badge upsert error:', badgeError);
   185→        continue;
   186→      }
   187→
   188→      // Update user's current badges
   189→      const { data: userData } = await supabase
   190→        .from('user_xp')
   191→        .select('current_badges')
   192→        .eq('user_id', badge.winner.userId)
   193→        .single();
   194→
   195→      const currentBadges = userData?.current_badges || [];
   196→      if (!currentBadges.includes(badge.id)) {
   197→        currentBadges.push(badge.id);
   198→        await supabase
   199→          .from('user_xp')
   200→          .update({ current_badges: currentBadges })
   201→          .eq('user_id', badge.winner.userId);
   202→      }
   203→
   204→      // Pulse event
   205→      await createPulseEvent({
   206→        event_type: 'badge_awarded',
   207→        user_id: badge.winner.userId,
   208→        user_name: badge.winner.name,
   209→        title: `${badge.emoji} ${badge.winner.name} earned "${badge.name}" badge!`,
   210→        subtitle: `This week's ${badge.name}`,
   211→        emoji: badge.emoji,
   212→      });
   213→    }
   214→
   215→    // Announce Player of the Week (highest XP)
   216→    const { data: weeklyXP } = await supabase
   217→      .from('xp_history')
   218→      .select('user_id, amount')
   219→      .gte('created_at', weekStart.toISOString());
   220→
   221→    const xpByUser = {};
   222→    (weeklyXP || []).forEach(row => {
   223→      xpByUser[row.user_id] = (xpByUser[row.user_id] || 0) + row.amount;
   224→    });
   225→
   226→    const potwEntries = Object.entries(xpByUser).sort(([, a], [, b]) => b - a);
   227→    const potw = potwEntries[0];
   228→
   229→    let playerOfWeek = null;
   230→    if (potw) {
   231→      const { data: potwUser } = await supabase
   232→        .from('user_xp')
   233→        .select('display_name')
   234→        .eq('user_id', potw[0])
   235→        .single();
   236→
   237→      playerOfWeek = {
   238→        userId: potw[0],
   239→        name: potwUser?.display_name || 'Anonymous',
   240→        xp: potw[1],
   241→      };
   242→
   243→      await createPulseEvent({
   244→        event_type: 'player_of_week',
   245→        user_id: potw[0],
   246→        user_name: potwUser?.display_name || 'Anonymous',
   247→        title: `PLAYER OF THE WEEK: ${potwUser?.display_name || 'Anonymous'}!`,
   248→        subtitle: `${potw[1]} XP earned this week`,
   249→        emoji: '👑',
   250→      });
   251→
   252→      // Notify Slack
   253→      try {
   254→        const slackService = require('./slack');
   255→        await slackService.sendNotification(
   256→          `*PLAYER OF THE WEEK*\n\n*${potwUser?.display_name || 'Anonymous'}* crushed it with ${potw[1]} XP!`,
   257→          '#sales-wins'
   258→        );
   259→      } catch (slackError) {
   260→        console.error('Slack notification error:', slackError);
   261→      }
   262→    }
   263→
   264→    return { badges, playerOfWeek };
   265→  } catch (error) {
   266→    console.error('awardWeeklyBadges error:', error);
   267→    return { badges: [], error: error.message };
   268→  }
   269→}
   270→
   271→/**
   272→ * Get the start of the week (Monday)
   273→ */
   274→function getWeekStart(date) {
   275→  const d = new Date(date);
   276→  const day = d.getDay();
   277→  const diff = d.getDate() - day + (day === 0 ? -6 : 1);
   278→  return new Date(d.setDate(diff));
   279→}
   280→
   281→/**
   282→ * Get user's badges
   283→ */
   284→async function getUserBadges(userId) {
   285→  try {
   286→    const { data, error } = await supabase
   287→      .from('weekly_badges')
   288→      .select('*')
   289→      .eq('user_id', userId)
   290→      .order('week_start', { ascending: false });
   291→
   292→    if (error) {
   293→      console.error('getUserBadges error:', error);
   294→      return [];
   295→    }
   296→
   297→    return data || [];
   298→  } catch (error) {
   299→    console.error('getUserBadges error:', error);
   300→    return [];
   301→  }
   302→}
   303→
   304→/**
   305→ * Get all badges for current week
   306→ */
   307→async function getCurrentWeekBadges() {
   308→  try {
   309→    const weekStart = getWeekStart(new Date());
   310→
   311→    const { data, error } = await supabase
   312→      .from('weekly_badges')
   313→      .select('*')
   314→      .eq('week_start', weekStart.toISOString().split('T')[0]);
   315→
   316→    if (error) {
   317→      console.error('getCurrentWeekBadges error:', error);
   318→      return [];
   319→    }
   320→
   321→    return data || [];
   322→  } catch (error) {
   323→    console.error('getCurrentWeekBadges error:', error);
   324→    return [];
   325→  }
   326→}
   327→
   328→/**
   329→ * Check if user has a specific badge type
   330→ */
   331→async function userHasBadge(userId, badgeType) {
   332→  try {
   333→    const { data } = await supabase
   334→      .from('weekly_badges')
   335→      .select('id')
   336→      .eq('user_id', userId)
   337→      .eq('badge_type', badgeType)
   338→      .limit(1);
   339→
   340→    return data?.length > 0;
   341→  } catch (error) {
   342→    console.error('userHasBadge error:', error);
   343→    return false;
   344→  }
   345→}
   346→
   347→module.exports = {
   348→  awardWeeklyBadges,
   349→  getUserBadges,
   350→  getCurrentWeekBadges,
   351→  userHasBadge,
   352→  getWeekStart,
   353→};
   354→

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