     1→/**
     2→ * Friction Detection Job
     3→ * Runs periodically to analyze usage patterns and detect friction points
     4→ *
     5→ * Detects:
     6→ * - Pages with high abandonment rates
     7→ * - Features with declining usage
     8→ * - Error-prone flows
     9→ * - Slow/confusing user paths
    10→ */
    11→
    12→const { supabase, supabaseAdmin } = require('../config/database');
    13→const { sendCriticalAlert } = require('../services/criticalAlerts');
    14→
    15→const db = supabaseAdmin || supabase;
    16→
    17→/**
    18→ * Analyze page abandonment rates
    19→ */
    20→async function analyzeAbandonmentRates() {
    21→  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
    22→
    23→  const { data: flowEvents, error } = await db
    24→    .from('usage_events')
    25→    .select('page, event_type')
    26→    .in('event_type', ['flow_complete', 'flow_abandon'])
    27→    .gte('created_at', sevenDaysAgo);
    28→
    29→  if (error || !flowEvents) {
    30→    console.error('[FrictionDetection] Error fetching flow events:', error);
    31→    return [];
    32→  }
    33→
    34→  // Group by page
    35→  const pageStats = {};
    36→  flowEvents.forEach(event => {
    37→    if (!event.page) return;
    38→    if (!pageStats[event.page]) {
    39→      pageStats[event.page] = { complete: 0, abandon: 0 };
    40→    }
    41→    if (event.event_type === 'flow_complete') {
    42→      pageStats[event.page].complete++;
    43→    } else {
    44→      pageStats[event.page].abandon++;
    45→    }
    46→  });
    47→
    48→  // Find high abandonment pages
    49→  const frictionPoints = [];
    50→  Object.entries(pageStats).forEach(([page, stats]) => {
    51→    const total = stats.complete + stats.abandon;
    52→    if (total < 5) return; // Need minimum sample size
    53→
    54→    const abandonRate = (stats.abandon / total) * 100;
    55→    if (abandonRate > 40) {
    56→      frictionPoints.push({
    57→        friction_type: 'high_abandonment',
    58→        location: page,
    59→        severity_score: Math.min(100, abandonRate),
    60→        evidence: {
    61→          complete: stats.complete,
    62→          abandon: stats.abandon,
    63→          abandon_rate: Math.round(abandonRate),
    64→          time_window: '7 days'
    65→        },
    66→        suggested_fix: `Review ${page} flow for usability issues. ${Math.round(abandonRate)}% of users abandon this page.`
    67→      });
    68→    }
    69→  });
    70→
    71→  return frictionPoints;
    72→}
    73→
    74→/**
    75→ * Analyze error patterns
    76→ */
    77→async function analyzeErrorPatterns() {
    78→  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
    79→
    80→  const { data: errors, error } = await db
    81→    .from('usage_events')
    82→    .select('page, event_name')
    83→    .eq('event_type', 'error')
    84→    .gte('created_at', sevenDaysAgo);
    85→
    86→  if (error || !errors) {
    87→    console.error('[FrictionDetection] Error fetching errors:', error);
    88→    return [];
    89→  }
    90→
    91→  // Group by page
    92→  const pageErrors = {};
    93→  errors.forEach(e => {
    94→    if (!e.page) return;
    95→    if (!pageErrors[e.page]) {
    96→      pageErrors[e.page] = { count: 0, types: {} };
    97→    }
    98→    pageErrors[e.page].count++;
    99→    const errType = e.event_name || 'unknown';
   100→    pageErrors[e.page].types[errType] = (pageErrors[e.page].types[errType] || 0) + 1;
   101→  });
   102→
   103→  // Find error-prone pages
   104→  const frictionPoints = [];
   105→  Object.entries(pageErrors).forEach(([page, data]) => {
   106→    if (data.count < 5) return; // Need minimum sample size
   107→
   108→    // Severity based on error count
   109→    const severity = Math.min(100, data.count * 2);
   110→
   111→    if (severity > 20) {
   112→      frictionPoints.push({
   113→        friction_type: 'error_prone',
   114→        location: page,
   115→        severity_score: severity,
   116→        evidence: {
   117→          error_count: data.count,
   118→          error_types: data.types,
   119→          time_window: '7 days'
   120→        },
   121→        suggested_fix: `Fix error handling on ${page}. ${data.count} errors in the last week.`
   122→      });
   123→    }
   124→  });
   125→
   126→  return frictionPoints;
   127→}
   128→
   129→/**
   130→ * Analyze rage click patterns
   131→ */
   132→async function analyzeRageClicks() {
   133→  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
   134→
   135→  const { data: rageClicks, error } = await db
   136→    .from('usage_events')
   137→    .select('page')
   138→    .eq('event_type', 'rage_click')
   139→    .gte('created_at', sevenDaysAgo);
   140→
   141→  if (error || !rageClicks) {
   142→    console.error('[FrictionDetection] Error fetching rage clicks:', error);
   143→    return [];
   144→  }
   145→
   146→  // Group by page
   147→  const pageRageClicks = {};
   148→  rageClicks.forEach(e => {
   149→    if (!e.page) return;
   150→    pageRageClicks[e.page] = (pageRageClicks[e.page] || 0) + 1;
   151→  });
   152→
   153→  // Find pages with significant rage clicks
   154→  const frictionPoints = [];
   155→  Object.entries(pageRageClicks).forEach(([page, count]) => {
   156→    if (count < 3) return; // Need minimum
   157→
   158→    const severity = Math.min(100, count * 5);
   159→    frictionPoints.push({
   160→      friction_type: 'rage_clicks',
   161→      location: page,
   162→      severity_score: severity,
   163→      evidence: {
   164→        rage_click_count: count,
   165→        time_window: '7 days'
   166→      },
   167→      suggested_fix: `Investigate user frustration on ${page}. ${count} rage clicks detected.`
   168→    });
   169→  });
   170→
   171→  return frictionPoints;
   172→}
   173→
   174→/**
   175→ * Detect declining feature usage
   176→ */
   177→async function analyzeUsageTrends() {
   178→  // Compare this week vs last week for each page
   179→  const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
   180→  const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
   181→
   182→  // Last week
   183→  const { data: lastWeek, error: lwError } = await db
   184→    .from('usage_events')
   185→    .select('page')
   186→    .eq('event_type', 'page_view')
   187→    .gte('created_at', twoWeeksAgo)
   188→    .lt('created_at', oneWeekAgo);
   189→
   190→  // This week
   191→  const { data: thisWeek, error: twError } = await db
   192→    .from('usage_events')
   193→    .select('page')
   194→    .eq('event_type', 'page_view')
   195→    .gte('created_at', oneWeekAgo);
   196→
   197→  if (lwError || twError || !lastWeek || !thisWeek) {
   198→    console.error('[FrictionDetection] Error fetching usage trends');
   199→    return [];
   200→  }
   201→
   202→  // Count by page
   203→  const lastWeekCounts = {};
   204→  lastWeek.forEach(e => {
   205→    if (e.page) {
   206→      lastWeekCounts[e.page] = (lastWeekCounts[e.page] || 0) + 1;
   207→    }
   208→  });
   209→
   210→  const thisWeekCounts = {};
   211→  thisWeek.forEach(e => {
   212→    if (e.page) {
   213→      thisWeekCounts[e.page] = (thisWeekCounts[e.page] || 0) + 1;
   214→    }
   215→  });
   216→
   217→  // Find declining pages
   218→  const frictionPoints = [];
   219→  Object.entries(lastWeekCounts).forEach(([page, lastCount]) => {
   220→    if (lastCount < 10) return; // Need meaningful baseline
   221→
   222→    const currentCount = thisWeekCounts[page] || 0;
   223→    const changePercent = ((currentCount - lastCount) / lastCount) * 100;
   224→
   225→    // Alert if usage dropped more than 50%
   226→    if (changePercent < -50) {
   227→      frictionPoints.push({
   228→        friction_type: 'declining_usage',
   229→        location: page,
   230→        severity_score: Math.min(100, Math.abs(changePercent)),
   231→        evidence: {
   232→          last_week: lastCount,
   233→          this_week: currentCount,
   234→          change_percent: Math.round(changePercent)
   235→        },
   236→        suggested_fix: `Investigate why ${page} usage dropped ${Math.abs(Math.round(changePercent))}% week-over-week.`
   237→      });
   238→    }
   239→  });
   240→
   241→  return frictionPoints;
   242→}
   243→
   244→/**
   245→ * Save detected friction points to database
   246→ */
   247→async function saveFrictionPoints(frictionPoints) {
   248→  const today = new Date().toISOString().split('T')[0];
   249→  let saved = 0;
   250→
   251→  for (const point of frictionPoints) {
   252→    // Check if similar friction already exists
   253→    const { data: existing } = await db
   254→      .from('detected_friction')
   255→      .select('id, severity_score')
   256→      .eq('friction_type', point.friction_type)
   257→      .eq('location', point.location)
   258→      .in('status', ['detected', 'acknowledged'])
   259→      .single();
   260→
   261→    if (existing) {
   262→      // Update if severity increased
   263→      if (point.severity_score > existing.severity_score) {
   264→        await db
   265→          .from('detected_friction')
   266→          .update({
   267→            severity_score: point.severity_score,
   268→            evidence: point.evidence,
   269→            detection_date: today
   270→          })
   271→          .eq('id', existing.id);
   272→        saved++;
   273→      }
   274→    } else {
   275→      // Insert new friction point
   276→      const { error } = await db
   277→        .from('detected_friction')
   278→        .insert({
   279→          detection_date: today,
   280→          friction_type: point.friction_type,
   281→          location: point.location,
   282→          severity_score: point.severity_score,
   283→          evidence: point.evidence,
   284→          suggested_fix: point.suggested_fix,
   285→          estimated_effort: point.severity_score > 70 ? 'high' : point.severity_score > 40 ? 'medium' : 'low',
   286→          status: 'detected'
   287→        });
   288→
   289→      if (!error) {
   290→        saved++;
   291→      }
   292→    }
   293→  }
   294→
   295→  return saved;
   296→}
   297→
   298→/**
   299→ * Main friction detection job
   300→ */
   301→async function runFrictionDetection() {
   302→  console.log('\n🔍 Starting Friction Detection Job...\n');
   303→
   304→  try {
   305→    const allFriction = [];
   306→
   307→    // Run all detection algorithms
   308→    console.log('[FrictionDetection] Analyzing abandonment rates...');
   309→    const abandonmentFriction = await analyzeAbandonmentRates();
   310→    allFriction.push(...abandonmentFriction);
   311→
   312→    console.log('[FrictionDetection] Analyzing error patterns...');
   313→    const errorFriction = await analyzeErrorPatterns();
   314→    allFriction.push(...errorFriction);
   315→
   316→    console.log('[FrictionDetection] Analyzing rage clicks...');
   317→    const rageFriction = await analyzeRageClicks();
   318→    allFriction.push(...rageFriction);
   319→
   320→    console.log('[FrictionDetection] Analyzing usage trends...');
   321→    const trendFriction = await analyzeUsageTrends();
   322→    allFriction.push(...trendFriction);
   323→
   324→    // Sort by severity
   325→    allFriction.sort((a, b) => b.severity_score - a.severity_score);
   326→
   327→    // Save to database
   328→    const saved = await saveFrictionPoints(allFriction);
   329→
   330→    console.log(`\n✅ Friction Detection Complete`);
   331→    console.log(`   Found: ${allFriction.length} friction points`);
   332→    console.log(`   Saved: ${saved} new/updated`);
   333→
   334→    // Alert if critical friction found
   335→    const critical = allFriction.filter(f => f.severity_score >= 80);
   336→    if (critical.length > 0) {
   337→      await sendCriticalAlert(
   338→        'Critical Friction Detected',
   339→        `*${critical.length} critical friction point(s)* found:\n` +
   340→        critical.slice(0, 3).map(f =>
   341→          `• ${f.location}: ${f.friction_type} (severity: ${Math.round(f.severity_score)})`
   342→        ).join('\n'),
   343→        'warning'
   344→      );
   345→    }
   346→
   347→    return { success: true, found: allFriction.length, saved };
   348→  } catch (error) {
   349→    console.error('❌ Friction detection failed:', error);
   350→    throw error;
   351→  }
   352→}
   353→
   354→module.exports = {
   355→  runFrictionDetection,
   356→  analyzeAbandonmentRates,
   357→  analyzeErrorPatterns,
   358→  analyzeRageClicks,
   359→  analyzeUsageTrends
   360→};
   361→

<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 Rep Performance Report
     3→ * Generates individual performance reports for each rep and sends to Slack
     4→ * Runs every Monday at 7:30am AEST
     5→ */
     6→
     7→const { generateWeeklyRepReport, formatDuration, formatHour } = require('../services/repPerformance');
     8→const { sendSlackMessage } = require('../services/slack');
     9→
    10→/**
    11→ * Format rep report for Slack
    12→ */
    13→function formatRepSlackBlock(rep) {
    14→  const { name, stats, prevStats, analysis } = rep;
    15→
    16→  // Calculate trend indicator
    17→  let trendIndicator = '';
    18→  if (prevStats && prevStats.conversionRate > 0) {
    19→    const change = stats.conversionRate - prevStats.conversionRate;
    20→    if (change > 0) {
    21→      trendIndicator = ` :chart_with_upwards_trend: +${change.toFixed(1)}%`;
    22→    } else if (change < 0) {
    23→      trendIndicator = ` :chart_with_downwards_trend: ${change.toFixed(1)}%`;
    24→    }
    25→  }
    26→
    27→  // Format strengths and weaknesses
    28→  const strengthText = analysis.strengths?.length > 0
    29→    ? `\n:muscle: *Strength:* ${analysis.strengths[0]}`
    30→    : '';
    31→
    32→  const weaknessText = analysis.weaknesses?.length > 0
    33→    ? `\n:warning: *Weakness:* ${analysis.weaknesses[0]}`
    34→    : '';
    35→
    36→  const coachingText = analysis.coachingTips?.length > 0
    37→    ? `\n:bulb: *AI TIP:* ${analysis.coachingTips[0]}`
    38→    : '';
    39→
    40→  // Best time
    41→  const bestTimeText = stats.bestHour !== null && stats.bestDay
    42→    ? `\n:trophy: *Best time:* ${stats.bestDay} ${formatHour(stats.bestHour)}`
    43→    : '';
    44→
    45→  return {
    46→    type: 'section',
    47→    text: {
    48→      type: 'mrkdwn',
    49→      text: `:bust_in_silhouette: *${name.toUpperCase()}*\nCalls: ${stats.totalCalls} | Conversions: ${stats.conversions} | Rate: ${stats.conversionRate}%\nAvg call: ${formatDuration(stats.avgCallDuration)}${stats.avgTalkRatio > 0 ? ` | Talk ratio: ${stats.avgTalkRatio}%` : ''}${stats.avgCallScore > 0 ? ` | Score: ${stats.avgCallScore}/10` : ''}${strengthText}${weaknessText}${coachingText}${trendIndicator}${bestTimeText}`
    50→    }
    51→  };
    52→}
    53→
    54→/**
    55→ * Send the weekly rep performance report to Slack
    56→ */
    57→async function sendWeeklyRepReportToSlack(report) {
    58→  const { weekStart, weekEnd, reps, highlights, teamTotals } = report;
    59→
    60→  // Build highlights section
    61→  const highlightLines = [];
    62→  if (highlights.topPerformer) {
    63→    highlightLines.push(`:medal: *TOP PERFORMER:* ${highlights.topPerformer.name} (${highlights.topPerformer.conversionRate}% conversion)`);
    64→  }
    65→  if (highlights.mostActive) {
    66→    highlightLines.push(`:telephone_receiver: *MOST ACTIVE:* ${highlights.mostActive.name} (${highlights.mostActive.calls} calls)`);
    67→  }
    68→  if (highlights.mostImproved) {
    69→    highlightLines.push(`:chart_with_upwards_trend: *MOST IMPROVED:* ${highlights.mostImproved.name} (+${highlights.mostImproved.improvement}%)`);
    70→  }
    71→
    72→  // Build rep blocks
    73→  const repBlocks = reps.slice(0, 5).map(rep => formatRepSlackBlock(rep));
    74→
    75→  const payload = {
    76→    text: ':bar_chart: Weekly Rep Performance Report',
    77→    attachments: [
    78→      {
    79→        color: '#6366f1',
    80→        blocks: [
    81→          {
    82→            type: 'header',
    83→            text: {
    84→              type: 'plain_text',
    85→              text: '📊 WEEKLY REP PERFORMANCE',
    86→              emoji: true
    87→            }
    88→          },
    89→          {
    90→            type: 'context',
    91→            elements: [
    92→              {
    93→                type: 'mrkdwn',
    94→                text: `Week of ${formatDate(weekStart)} - ${formatDate(weekEnd)}`
    95→              }
    96→            ]
    97→          },
    98→          {
    99→            type: 'divider'
   100→          },
   101→          ...repBlocks,
   102→          {
   103→            type: 'divider'
   104→          },
   105→          {
   106→            type: 'section',
   107→            text: {
   108→              type: 'mrkdwn',
   109→              text: highlightLines.join('\n')
   110→            }
   111→          },
   112→          {
   113→            type: 'divider'
   114→          },
   115→          {
   116→            type: 'section',
   117→            text: {
   118→              type: 'mrkdwn',
   119→              text: `:dart: *TEAM TOTALS*\nCalls: ${teamTotals.calls} | Conversions: ${teamTotals.conversions} | Avg Rate: ${teamTotals.avgConversionRate}%`
   120→            }
   121→          },
   122→          {
   123→            type: 'context',
   124→            elements: [
   125→              {
   126→                type: 'mrkdwn',
   127→                text: ':robot_face: Generated by RateRight Growth Engine'
   128→              }
   129→            ]
   130→          }
   131→        ]
   132→      }
   133→    ]
   134→  };
   135→
   136→  return sendSlackMessage(payload);
   137→}
   138→
   139→/**
   140→ * Format date for display
   141→ */
   142→function formatDate(dateStr) {
   143→  const date = new Date(dateStr);
   144→  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
   145→  return `${months[date.getMonth()]} ${date.getDate()}`;
   146→}
   147→
   148→/**
   149→ * Run the weekly rep performance report
   150→ */
   151→async function runWeeklyRepReport() {
   152→  console.log('[WeeklyRepReport] Starting weekly rep performance report...');
   153→
   154→  try {
   155→    // Generate report data
   156→    const report = await generateWeeklyRepReport();
   157→
   158→    if (!report.success) {
   159→      console.log('[WeeklyRepReport] No data to report:', report.reason);
   160→      return { success: false, reason: report.reason };
   161→    }
   162→
   163→    if (report.reps.length === 0) {
   164→      console.log('[WeeklyRepReport] No reps with activity this week');
   165→      return {
   166→        success: false,
   167→        reason: 'No activity',
   168→        weekStart: report.weekStart,
   169→        weekEnd: report.weekEnd,
   170→        repsChecked: report.repsChecked,
   171→      };
   172→    }
   173→
   174→    // Send to Slack
   175→    const slackResult = await sendWeeklyRepReportToSlack(report);
   176→
   177→    if (slackResult.success) {
   178→      console.log(`[WeeklyRepReport] Sent to Slack - ${report.reps.length} reps reported`);
   179→    } else {
   180→      console.log('[WeeklyRepReport] Slack send failed:', slackResult.error);
   181→    }
   182→
   183→    return {
   184→      success: true,
   185→      report,
   186→      slackSent: slackResult.success,
   187→    };
   188→
   189→  } catch (error) {
   190→    console.error('[WeeklyRepReport] Error:', error);
   191→    return { success: false, error: error.message };
   192→  }
   193→}
   194→
   195→module.exports = {
   196→  runWeeklyRepReport,
   197→  sendWeeklyRepReportToSlack,
   198→};
   199→

<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→ * Sequence Processor Job
     3→ * Processes automated SMS sequences - runs every 60 seconds
     4→ *
     5→ * Flow:
     6→ * 1. Find enrollments where next_step_at <= NOW and status = 'active'
     7→ * 2. For each enrollment:
     8→ *    a. Get current step
     9→ *    b. If SMS step: send message via Twilio and log to communications
    10→ *    c. If call_reminder: create callback
    11→ *    d. Increment current_step
    12→ *    e. Calculate next_step_at based on next step's delay_hours
    13→ *    f. If no more steps: mark completed
    14→ */
    15→
    16→const { supabase, supabaseAdmin } = require('../config/database');
    17→
    18→// Use admin client to bypass RLS on sequence tables
    19→const db = supabaseAdmin || supabase;
    20→const { sendSMS, isConfigured } = require('../services/twilio');
    21→const { normalizeAustralianPhone } = require('../utils/phone');
    22→
    23→// AEST business hours (8am-6pm) - don't send outside these
    24→const BUSINESS_START_HOUR = 8;
    25→const BUSINESS_END_HOUR = 18;
    26→const AEST_OFFSET_MS = 10 * 60 * 60 * 1000;
    27→
    28→// Send windows for tradie-optimized timing (AEST hours, will be converted)
    29→const SEND_WINDOWS = {
    30→  morning: { startHour: 6, endHour: 7, endMinute: 30 },   // 6:00-7:30am - before site starts
    31→  lunch: { startHour: 12, endHour: 13, endMinute: 0 },    // 12:00-1:00pm - lunch break
    32→  knockoff: { startHour: 15, endHour: 18, endMinute: 0 }, // 3:00-6:00pm - after work
    33→  any: { startHour: 8, endHour: 18, endMinute: 0 }        // 8:00am-6:00pm - general business
    34→};
    35→
    36→/**
    37→ * Get random integer between min and max (inclusive)
    38→ */
    39→function randomInt(min, max) {
    40→  return Math.floor(Math.random() * (max - min + 1)) + min;
    41→}
    42→
    43→/**
    44→ * Get random time within a send window (returns AEST time components)
    45→ */
    46→function getRandomTimeInWindow(sendWindow) {
    47→  const window = SEND_WINDOWS[sendWindow] || SEND_WINDOWS.any;
    48→
    49→  // Random hour in range
    50→  const hour = randomInt(window.startHour, window.endHour - 1);
    51→
    52→  // Random minute (0-59, or 0-endMinute for last hour)
    53→  let maxMinute = 59;
    54→  if (hour === window.endHour - 1 && window.endMinute > 0) {
    55→    maxMinute = window.endMinute;
    56→  }
    57→  const minute = randomInt(0, maxMinute);
    58→
    59→  return { hour, minute };
    60→}
    61→
    62→/**
    63→ * Calculate next step time with randomization
    64→ * Uses delay_days_min/max and send_window for human-like variability
    65→ */
    66→function calculateNextStepAt(step) {
    67→  const now = new Date();
    68→
    69→  // Get delay in days (support both new and old format)
    70→  let delayDays;
    71→  if (step.delay_days_min !== null && step.delay_days_max !== null &&
    72→      (step.delay_days_min > 0 || step.delay_days_max > 0)) {
    73→    // v2: Random day within range
    74→    delayDays = randomInt(step.delay_days_min, step.delay_days_max);
    75→  } else if (step.delay_hours) {
    76→    // v1 fallback: Convert hours to days
    77→    delayDays = Math.floor(step.delay_hours / 24);
    78→  } else {
    79→    delayDays = 1; // Default to 1 day
    80→  }
    81→
    82→  // Start with today/tomorrow at midnight AEST
    83→  const targetDate = new Date(now.getTime() + AEST_OFFSET_MS);
    84→  targetDate.setUTCHours(0, 0, 0, 0);
    85→  targetDate.setUTCDate(targetDate.getUTCDate() + delayDays);
    86→
    87→  // Skip weekends
    88→  while (targetDate.getUTCDay() === 0 || targetDate.getUTCDay() === 6) {
    89→    targetDate.setUTCDate(targetDate.getUTCDate() + 1);
    90→  }
    91→
    92→  // Get random time within send window
    93→  const sendWindow = step.send_window || 'any';
    94→  const { hour, minute } = getRandomTimeInWindow(sendWindow);
    95→
    96→  // Set the time (still in AEST-adjusted date)
    97→  targetDate.setUTCHours(hour, minute, randomInt(0, 59), 0);
    98→
    99→  // Convert back to UTC
   100→  return new Date(targetDate.getTime() - AEST_OFFSET_MS);
   101→}
   102→
   103→/**
   104→ * Check if current time is within AEST business hours
   105→ */
   106→function isWithinBusinessHours() {
   107→  const now = new Date();
   108→  const aestTime = new Date(now.getTime() + AEST_OFFSET_MS);
   109→  const hour = aestTime.getUTCHours();
   110→  const dayOfWeek = aestTime.getUTCDay(); // 0=Sunday, 6=Saturday
   111→
   112→  // Don't send on weekends
   113→  if (dayOfWeek === 0 || dayOfWeek === 6) {
   114→    return false;
   115→  }
   116→
   117→  return hour >= BUSINESS_START_HOUR && hour < BUSINESS_END_HOUR;
   118→}
   119→
   120→/**
   121→ * Calculate next business day 8am AEST
   122→ */
   123→function getNextBusinessDayStart() {
   124→  const now = new Date();
   125→  const aestTime = new Date(now.getTime() + AEST_OFFSET_MS);
   126→
   127→  // Move to next day at 8am
   128→  aestTime.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
   129→  aestTime.setUTCDate(aestTime.getUTCDate() + 1);
   130→
   131→  // Skip weekends
   132→  while (aestTime.getUTCDay() === 0 || aestTime.getUTCDay() === 6) {
   133→    aestTime.setUTCDate(aestTime.getUTCDate() + 1);
   134→  }
   135→
   136→  // Convert back to UTC
   137→  return new Date(aestTime.getTime() - AEST_OFFSET_MS);
   138→}
   139→
   140→/**
   141→ * Replace template variables with lead data
   142→ */
   143→function personalizeMessage(template, lead) {
   144→  if (!template) return '';
   145→
   146→  let message = template;
   147→
   148→  // Standard replacements
   149→  const fullName = [lead?.first_name, lead?.last_name].filter(Boolean).join(' ') || 'there';
   150→  const replacements = {
   151→    '{first_name}': lead?.first_name || 'there',
   152→    '{name}': fullName,
   153→    '{trade}': lead?.trade || lead?.metadata?.trade || 'trade',
   154→    '{company}': lead?.company || '',
   155→    '{sender_name}': 'Rocky', // Default sender name
   156→  };
   157→
   158→  for (const [key, value] of Object.entries(replacements)) {
   159→    message = message.replace(new RegExp(key, 'g'), value);
   160→  }
   161→
   162→  return message;
   163→}
   164→
   165→/**
   166→ * Process a single enrollment step
   167→ */
   168→async function processEnrollmentStep(enrollment, step, lead) {
   169→  const results = {
   170→    success: false,
   171→    action: null,
   172→    error: null,
   173→    communicationId: null,
   174→    callbackId: null
   175→  };
   176→
   177→  try {
   178→    if (step.action_type === 'sms') {
   179→      // DUPLICATE PROTECTION: Check if we sent SMS to this lead in last 30 minutes
   180→      const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
   181→      const { data: recentSms } = await db
   182→        .from('communications')
   183→        .select('id, created_at')
   184→        .eq('lead_id', lead.id)
   185→        .eq('type', 'sms_outbound')
   186→        .gte('created_at', thirtyMinutesAgo)
   187→        .limit(1);
   188→
   189→      if (recentSms && recentSms.length > 0) {
   190→        const minutesAgo = Math.round((Date.now() - new Date(recentSms[0].created_at).getTime()) / 60000);
   191→        console.log(`[SequenceProcessor] THROTTLED: Lead ${lead.id} (${lead.first_name}) received SMS ${minutesAgo} minutes ago - rescheduling`);
   192→
   193→        results.success = false;
   194→        results.action = 'throttled';
   195→        results.error = `Lead received SMS ${minutesAgo} minutes ago, will retry later`;
   196→        return results;
   197→      }
   198→
   199→      // Get message template (from step or referenced template)
   200→      let messageTemplate = step.message_template;
   201→
   202→      if (!messageTemplate && step.template_id) {
   203→        const { data: template } = await db
   204→          .from('sms_templates')
   205→          .select('content')
   206→          .eq('id', step.template_id)
   207→          .single();
   208→        messageTemplate = template?.content;
   209→      }
   210→
   211→      if (!messageTemplate) {
   212→        results.error = 'No message template found';
   213→        return results;
   214→      }
   215→
   216→      // Personalize message
   217→      const message = personalizeMessage(messageTemplate, lead);
   218→
   219→      if (!lead?.phone) {
   220→        results.error = 'Lead has no phone number';
   221→        return results;
   222→      }
   223→
   224→      // Send SMS via Twilio (normalize phone to E.164)
   225→      const normalizedPhone = normalizeAustralianPhone(lead.phone) || lead.phone;
   226→      const smsResult = await sendSMS(normalizedPhone, message);
   227→
   228→      if (smsResult.success) {
   229→        // Log to communications table
   230→        const { data: comm, error: commError } = await db
   231→          .from('communications')
   232→          .insert({
   233→            lead_id: lead.id,
   234→            type: 'sms_outbound',
   235→            direction: 'outbound',
   236→            phone: lead.phone,
   237→            content: message,
   238→            external_id: smsResult.messageSid,
   239→            status: 'sent',
   240→            metadata: {
   241→              sequence_id: enrollment.sequence_id,
   242→              sequence_step: step.step_order,
   243→              sequence_enrollment_id: enrollment.id,
   244→              sent_via: 'sequence_automation',
   245→              sent_at: new Date().toISOString()
   246→            }
   247→          })
   248→          .select('id')
   249→          .single();
   250→
   251→        if (commError) {
   252→          console.error('[SequenceProcessor] Failed to log communication:', commError.message);
   253→        } else {
   254→          results.communicationId = comm?.id;
   255→        }
   256→
   257→        // Update lead last_contact
   258→        await db
   259→          .from('leads')
   260→          .update({
   261→            last_contact_at: new Date().toISOString(),
   262→            last_contact_type: 'sms_outbound'
   263→          })
   264→          .eq('id', lead.id);
   265→
   266→        results.success = true;
   267→        results.action = 'sms_sent';
   268→        console.log(`[SequenceProcessor] Sent SMS to ${lead.phone} (step ${step.step_order})`);
   269→      } else {
   270→        results.error = smsResult.error || 'SMS send failed';
   271→      }
   272→
   273→    } else if (step.action_type === 'call_reminder') {
   274→      // Create a callback for manual follow-up
   275→      const callbackDate = new Date();
   276→      callbackDate.setHours(callbackDate.getHours() + (step.delay_hours || 24));
   277→
   278→      const { data: callback, error: cbError } = await db
   279→        .from('callbacks')
   280→        .insert({
   281→          lead_id: lead.id,
   282→          scheduled_at: callbackDate.toISOString(),
   283→          priority: 'medium',
   284→          status: 'pending',
   285→          source_type: 'sequence',
   286→          notes: step.config?.notes || `Sequence follow-up call (step ${step.step_order})`
   287→        })
   288→        .select('id')
   289→        .single();
   290→
   291→      if (cbError) {
   292→        results.error = cbError.message;
   293→      } else {
   294→        results.callbackId = callback?.id;
   295→        results.success = true;
   296→        results.action = 'callback_created';
   297→        console.log(`[SequenceProcessor] Created callback for ${lead.first_name} (step ${step.step_order})`);
   298→      }
   299→
   300→    } else if (step.action_type === 'wait') {
   301→      // Wait steps just advance to next step
   302→      results.success = true;
   303→      results.action = 'wait_completed';
   304→    } else {
   305→      results.error = `Unknown action type: ${step.action_type}`;
   306→    }
   307→
   308→  } catch (err) {
   309→    results.error = err.message;
   310→    console.error('[SequenceProcessor] Error processing step:', err);
   311→  }
   312→
   313→  return results;
   314→}
   315→
   316→/**
   317→ * Main processor - find and process due enrollments
   318→ */
   319→async function processSequences() {
   320→  if (!isConfigured()) {
   321→    console.log('[SequenceProcessor] Twilio not configured, skipping');
   322→    return { processed: 0, sent: 0, callbacks: 0, failed: 0, skipped_hours: 0 };
   323→  }
   324→
   325→  // Check business hours
   326→  if (!isWithinBusinessHours()) {
   327→    // Reschedule any due messages to next business day
   328→    const nextBusinessDay = getNextBusinessDayStart();
   329→
   330→    const { data: dueEnrollments } = await db
   331→      .from('sequence_enrollments')
   332→      .select('id')
   333→      .eq('status', 'active')
   334→      .lte('next_step_at', new Date().toISOString());
   335→
   336→    if (dueEnrollments?.length > 0) {
   337→      console.log(`[SequenceProcessor] Outside business hours - rescheduling ${dueEnrollments.length} enrollments to ${nextBusinessDay.toISOString()}`);
   338→
   339→      await db
   340→        .from('sequence_enrollments')
   341→        .update({ next_step_at: nextBusinessDay.toISOString() })
   342→        .in('id', dueEnrollments.map(e => e.id));
   343→    }
   344→
   345→    return { processed: 0, sent: 0, callbacks: 0, failed: 0, skipped_hours: dueEnrollments?.length || 0 };
   346→  }
   347→
   348→  const now = new Date().toISOString();
   349→
   350→  // Get due enrollments with sequence and lead data
   351→  const { data: enrollments, error } = await db
   352→    .from('sequence_enrollments')
   353→    .select(`
   354→      *,
   355→      sequence:sequences!sequence_enrollments_sequence_id_fkey(id, name, channel, is_active),
   356→      lead:leads!sequence_enrollments_lead_id_fkey(id, first_name, last_name, phone, email, company, metadata, status)
   357→    `)
   358→    .eq('status', 'active')
   359→    .lte('next_step_at', now)
   360→    .order('next_step_at', { ascending: true })
   361→    .limit(20); // Process 20 at a time
   362→
   363→  if (error) {
   364→    console.error('[SequenceProcessor] Failed to fetch enrollments:', error);
   365→    return { processed: 0, sent: 0, callbacks: 0, failed: 0, error: error.message };
   366→  }
   367→
   368→  if (!enrollments || enrollments.length === 0) {
   369→    return { processed: 0, sent: 0, callbacks: 0, failed: 0 };
   370→  }
   371→
   372→  console.log(`[SequenceProcessor] Processing ${enrollments.length} due enrollments`);
   373→
   374→  let sent = 0;
   375→  let callbacks = 0;
   376→  let failed = 0;
   377→
   378→  for (const enrollment of enrollments) {
   379→    // Skip if sequence is inactive
   380→    if (!enrollment.sequence?.is_active) {
   381→      await db
   382→        .from('sequence_enrollments')
   383→        .update({
   384→          status: 'paused',
   385→          pause_reason: 'sequence_disabled'
   386→        })
   387→        .eq('id', enrollment.id);
   388→      continue;
   389→    }
   390→
   391→    // Skip if lead is converted, opted out, or archived
   392→    const skipStatuses = ['converted', 'opted_out', 'archived'];
   393→    if (skipStatuses.includes(enrollment.lead?.status)) {
   394→      await db
   395→        .from('sequence_enrollments')
   396→        .update({
   397→          status: 'completed',
   398→          pause_reason: `lead_${enrollment.lead.status}`
   399→        })
   400→        .eq('id', enrollment.id);
   401→      continue;
   402→    }
   403→
   404→    // Get all steps for this sequence
   405→    const { data: steps } = await db
   406→      .from('sequence_steps')
   407→      .select('*')
   408→      .eq('sequence_id', enrollment.sequence_id)
   409→      .order('step_order', { ascending: true });
   410→
   411→    if (!steps || steps.length === 0) {
   412→      console.warn(`[SequenceProcessor] No steps found for sequence ${enrollment.sequence_id}`);
   413→      continue;
   414→    }
   415→
   416→    // Get current step (0-indexed, but step_order is 1-indexed)
   417→    const currentStepIndex = enrollment.current_step;
   418→    const currentStep = steps[currentStepIndex];
   419→
   420→    if (!currentStep) {
   421→      // No more steps - sequence complete
   422→      await db
   423→        .from('sequence_enrollments')
   424→        .update({
   425→          status: 'completed',
   426→          completed_at: new Date().toISOString()
   427→        })
   428→        .eq('id', enrollment.id);
   429→      console.log(`[SequenceProcessor] Sequence completed for lead ${enrollment.lead?.first_name}`);
   430→      continue;
   431→    }
   432→
   433→    // Process the step
   434→    const result = await processEnrollmentStep(enrollment, currentStep, enrollment.lead);
   435→
   436→    // Handle throttled separately - reschedule for 1 hour later
   437→    if (result.action === 'throttled') {
   438→      const rescheduleAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour later
   439→      await db
   440→        .from('sequence_enrollments')
   441→        .update({
   442→          next_step_at: rescheduleAt.toISOString()
   443→        })
   444→        .eq('id', enrollment.id);
   445→      console.log(`[SequenceProcessor] Rescheduled throttled enrollment ${enrollment.id} to ${rescheduleAt.toISOString()}`);
   446→      continue;
   447→    }
   448→
   449→    if (result.success) {
   450→      if (result.action === 'sms_sent') sent++;
   451→      if (result.action === 'callback_created') callbacks++;
   452→
   453→      // Move to next step
   454→      const nextStepIndex = currentStepIndex + 1;
   455→      const nextStep = steps[nextStepIndex];
   456→
   457→      if (nextStep) {
   458→        // Calculate next_step_at with randomization (v2) or fixed delay (v1)
   459→        const nextStepAt = calculateNextStepAt(nextStep);
   460→
   461→        await db
   462→          .from('sequence_enrollments')
   463→          .update({
   464→            current_step: nextStepIndex,
   465→            next_step_at: nextStepAt.toISOString(),
   466→            failed_attempts: 0  // Reset on success
   467→          })
   468→          .eq('id', enrollment.id);
   469→      } else {
   470→        // No more steps - sequence complete
   471→        await db
   472→          .from('sequence_enrollments')
   473→          .update({
   474→            status: 'completed',
   475→            completed_at: new Date().toISOString(),
   476→            failed_attempts: 0  // Reset on success
   477→          })
   478→          .eq('id', enrollment.id);
   479→        console.log(`[SequenceProcessor] Sequence completed for lead ${enrollment.lead?.first_name}`);
   480→      }
   481→    } else {
   482→      failed++;
   483→      console.error(`[SequenceProcessor] Failed to process step for enrollment ${enrollment.id}:`, result.error);
   484→
   485→      // Retry logic: track failed attempts, pause after 3 failures
   486→      const currentFailedAttempts = (enrollment.failed_attempts || 0) + 1;
   487→      const MAX_RETRIES = 3;
   488→
   489→      if (currentFailedAttempts >= MAX_RETRIES) {
   490→        // Max retries exceeded - pause enrollment
   491→        await db
   492→          .from('sequence_enrollments')
   493→          .update({
   494→            status: 'paused',
   495→            paused_reason: `SMS failed ${MAX_RETRIES} times: ${result.error}`,
   496→            failed_attempts: currentFailedAttempts
   497→          })
   498→          .eq('id', enrollment.id);
   499→
   500→        console.log(`[SequenceProcessor] Paused enrollment ${enrollment.id} after ${MAX_RETRIES} failures`);
   501→
   502→        // Send Slack alert for failed enrollment
   503→        try {
   504→          const slack = require('../services/slack');
   505→          await slack.sendAlert(
   506→            `Sequence paused: ${enrollment.lead?.first_name} ${enrollment.lead?.last_name}`,
   507→            `Failed ${MAX_RETRIES} times: ${result.error}\nPhone: ${enrollment.lead?.phone}`,
   508→            'warning'
   509→          );
   510→        } catch (slackErr) {
   511→          console.error('[SequenceProcessor] Failed to send Slack alert:', slackErr.message);
   512→        }
   513→      } else {
   514→        // Retry later - schedule for next hour, don't advance step
   515→        const retryAt = new Date();
   516→        retryAt.setHours(retryAt.getHours() + 1);
   517→
   518→        await db
   519→          .from('sequence_enrollments')
   520→          .update({
   521→            failed_attempts: currentFailedAttempts,
   522→            next_step_at: retryAt.toISOString()
   523→          })
   524→          .eq('id', enrollment.id);
   525→
   526→        console.log(`[SequenceProcessor] Will retry enrollment ${enrollment.id} (attempt ${currentFailedAttempts}/${MAX_RETRIES})`);
   527→      }
   528→    }
   529→  }
   530→
   531→  console.log(`[SequenceProcessor] Completed: ${sent} SMS sent, ${callbacks} callbacks, ${failed} failed`);
   532→
   533→  return {
   534→    processed: enrollments.length,
   535→    sent,
   536→    callbacks,
   537→    failed
   538→  };
   539→}
   540→
   541→module.exports = {
   542→  processSequences
   543→};
   544→

<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 remaining job files (batch 2).


TodoWrite

Read entry point and config files

Read all middleware files

Read all utility files

Read all job files (batch 1)

Read all job files (batch 2)

Read .env and remaining files

Compile comprehensive audit report


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


     1→/**
     2→ * Scheduled SMS Sender Job
     3→ * Checks for and sends scheduled SMS messages
     4→ */
     5→
     6→const { supabase, supabaseAdmin } = require('../config/database');
     7→
     8→// Use admin client to bypass RLS (future-proofing)
     9→const db = supabaseAdmin || supabase;
    10→const { sendSMS, isConfigured } = require('../services/twilio');
    11→
    12→// Retry configuration
    13→const MAX_RETRIES = 3;
    14→const RETRY_DELAYS_MS = [60000, 300000, 900000]; // 1 min, 5 min, 15 min exponential backoff
    15→
    16→/**
    17→ * Process scheduled messages that are due to be sent
    18→ */
    19→async function processScheduledMessages() {
    20→  if (!isConfigured()) {
    21→    console.log('[ScheduledSMS] Twilio not configured, skipping');
    22→    return { processed: 0, sent: 0, failed: 0 };
    23→  }
    24→
    25→  const now = new Date().toISOString();
    26→
    27→  // Get all scheduled messages that are due
    28→  const { data: messages, error } = await db
    29→    .from('communications')
    30→    .select('*')
    31→    .eq('status', 'scheduled')
    32→    .lte('scheduled_at', now)
    33→    .order('scheduled_at', { ascending: true })
    34→    .limit(50); // Process 50 at a time to avoid overwhelming Twilio
    35→
    36→  if (error) {
    37→    console.error('[ScheduledSMS] Failed to fetch scheduled messages:', error);
    38→    return { processed: 0, sent: 0, failed: 0, error: error.message };
    39→  }
    40→
    41→  if (!messages || messages.length === 0) {
    42→    return { processed: 0, sent: 0, failed: 0 };
    43→  }
    44→
    45→  console.log(`[ScheduledSMS] Processing ${messages.length} scheduled messages`);
    46→
    47→  let sent = 0;
    48→  let failed = 0;
    49→
    50→  for (const message of messages) {
    51→    try {
    52→      // Send the SMS
    53→      const result = await sendSMS(message.phone, message.content);
    54→
    55→      if (result.success) {
    56→        // Update status to sent
    57→        await db
    58→          .from('communications')
    59→          .update({
    60→            status: 'sent',
    61→            external_id: result.messageSid,
    62→            metadata: {
    63→              ...message.metadata,
    64→              sent_at: new Date().toISOString(),
    65→              was_scheduled: true
    66→            }
    67→          })
    68→          .eq('id', message.id);
    69→
    70→        // Update lead last_contact if we have a lead_id
    71→        if (message.lead_id) {
    72→          await db
    73→            .from('leads')
    74→            .update({
    75→              last_contact_at: new Date().toISOString(),
    76→              last_contact_type: 'sms_outbound'
    77→            })
    78→            .eq('id', message.lead_id);
    79→        }
    80→
    81→        sent++;
    82→        console.log(`[ScheduledSMS] Sent message ${message.id} to ${message.phone}`);
    83→      } else {
    84→        // Check retry count and schedule retry or mark as failed
    85→        const retryCount = (message.metadata?.retry_count || 0) + 1;
    86→
    87→        if (retryCount < MAX_RETRIES) {
    88→          // Schedule retry with exponential backoff
    89→          const retryDelay = RETRY_DELAYS_MS[retryCount - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1];
    90→          const retryAt = new Date(Date.now() + retryDelay).toISOString();
    91→
    92→          await db
    93→            .from('communications')
    94→            .update({
    95→              status: 'scheduled',
    96→              scheduled_at: retryAt,
    97→              metadata: {
    98→                ...message.metadata,
    99→                retry_count: retryCount,
   100→                last_error: result.error,
   101→                last_retry_at: new Date().toISOString()
   102→              }
   103→            })
   104→            .eq('id', message.id);
   105→
   106→          console.log(`[ScheduledSMS] Message ${message.id} failed, retry ${retryCount}/${MAX_RETRIES} scheduled for ${retryAt}`);
   107→        } else {
   108→          // Max retries exceeded, mark as permanently failed
   109→          await db
   110→            .from('communications')
   111→            .update({
   112→              status: 'failed',
   113→              metadata: {
   114→                ...message.metadata,
   115→                retry_count: retryCount,
   116→                error: result.error,
   117→                failed_at: new Date().toISOString(),
   118→                final_failure: true
   119→              }
   120→            })
   121→            .eq('id', message.id);
   122→
   123→          failed++;
   124→          console.error(`[ScheduledSMS] Message ${message.id} failed after ${MAX_RETRIES} retries:`, result.error);
   125→        }
   126→      }
   127→    } catch (err) {
   128→      // Check retry count for exceptions too
   129→      const retryCount = (message.metadata?.retry_count || 0) + 1;
   130→
   131→      if (retryCount < MAX_RETRIES) {
   132→        const retryDelay = RETRY_DELAYS_MS[retryCount - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1];
   133→        const retryAt = new Date(Date.now() + retryDelay).toISOString();
   134→
   135→        await db
   136→          .from('communications')
   137→          .update({
   138→            status: 'scheduled',
   139→            scheduled_at: retryAt,
   140→            metadata: {
   141→              ...message.metadata,
   142→              retry_count: retryCount,
   143→              last_error: err.message,
   144→              last_retry_at: new Date().toISOString()
   145→            }
   146→          })
   147→          .eq('id', message.id);
   148→
   149→        console.log(`[ScheduledSMS] Message ${message.id} error, retry ${retryCount}/${MAX_RETRIES} scheduled`);
   150→      } else {
   151→        await db
   152→          .from('communications')
   153→          .update({
   154→            status: 'failed',
   155→            metadata: {
   156→              ...message.metadata,
   157→              retry_count: retryCount,
   158→              error: err.message,
   159→              failed_at: new Date().toISOString(),
   160→              final_failure: true
   161→            }
   162→          })
   163→          .eq('id', message.id);
   164→
   165→        failed++;
   166→        console.error(`[ScheduledSMS] Message ${message.id} failed after ${MAX_RETRIES} retries:`, err.message);
   167→      }
   168→    }
   169→  }
   170→
   171→  console.log(`[ScheduledSMS] Completed: ${sent} sent, ${failed} failed`);
   172→
   173→  return {
   174→    processed: messages.length,
   175→    sent,
   176→    failed
   177→  };
   178→}
   179→
   180→module.exports = {
   181→  processScheduledMessages
   182→};
   183→

<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→ * Release Stale Lead Assignments Job
     3→ * Releases leads that have been assigned but have no activity for 24 hours
     4→ */
     5→
     6→const { supabase, supabaseAdmin } = require('../config/database');
     7→
     8→// Use admin client if available (bypasses RLS)
     9→const db = supabaseAdmin || supabase;
    10→
    11→/**
    12→ * Run the stale assignment release job
    13→ * Finds leads assigned > 24 hours ago with no recent activity and releases them
    14→ */
    15→async function runReleaseStaleAssignments() {
    16→  console.log('[ReleaseStaleAssignments] Starting...');
    17→
    18→  if (!db) {
    19→    console.error('[ReleaseStaleAssignments] Database not configured');
    20→    return { success: false, error: 'Database not configured' };
    21→  }
    22→
    23→  try {
    24→    const staleThreshold = new Date();
    25→    staleThreshold.setHours(staleThreshold.getHours() - 24);
    26→    const staleThresholdISO = staleThreshold.toISOString();
    27→
    28→    // Find leads that have been assigned for > 24 hours
    29→    const { data: staleLeads, error: fetchError } = await db
    30→      .from('leads')
    31→      .select('id, assigned_to, assigned_at, first_name, last_name')
    32→      .not('assigned_to', 'is', null)
    33→      .lt('assigned_at', staleThresholdISO)
    34→      .is('deleted_at', null)
    35→      .limit(100); // Process in batches
    36→
    37→    if (fetchError) {
    38→      console.error('[ReleaseStaleAssignments] Fetch error:', fetchError.message);
    39→      return { success: false, error: fetchError.message };
    40→    }
    41→
    42→    if (!staleLeads || staleLeads.length === 0) {
    43→      console.log('[ReleaseStaleAssignments] No stale assignments found');
    44→      return { success: true, released: 0 };
    45→    }
    46→
    47→    console.log(`[ReleaseStaleAssignments] Found ${staleLeads.length} potentially stale assignments`);
    48→
    49→    let releasedCount = 0;
    50→    const released = [];
    51→
    52→    for (const lead of staleLeads) {
    53→      // Check if there's any recent activity (communications in last 24h)
    54→      const { data: recentComms, error: commsError } = await db
    55→        .from('communications')
    56→        .select('id')
    57→        .eq('lead_id', lead.id)
    58→        .gte('created_at', staleThresholdISO)
    59→        .limit(1);
    60→
    61→      if (commsError) {
    62→        console.warn(`[ReleaseStaleAssignments] Error checking comms for lead ${lead.id}:`, commsError.message);
    63→        continue;
    64→      }
    65→
    66→      // If there's recent activity, don't release - just update assigned_at
    67→      if (recentComms && recentComms.length > 0) {
    68→        // Update assigned_at to now (reset the 24h clock)
    69→        await db
    70→          .from('leads')
    71→          .update({ assigned_at: new Date().toISOString() })
    72→          .eq('id', lead.id);
    73→        console.log(`[ReleaseStaleAssignments] Lead ${lead.id} has activity, refreshed assignment`);
    74→        continue;
    75→      }
    76→
    77→      // No activity - release the lead
    78→      const { error: releaseError } = await db
    79→        .from('leads')
    80→        .update({
    81→          assigned_to: null,
    82→          assigned_at: null,
    83→          updated_at: new Date().toISOString()
    84→        })
    85→        .eq('id', lead.id);
    86→
    87→      if (releaseError) {
    88→        console.warn(`[ReleaseStaleAssignments] Failed to release lead ${lead.id}:`, releaseError.message);
    89→        continue;
    90→      }
    91→
    92→      releasedCount++;
    93→      released.push({
    94→        id: lead.id,
    95→        name: `${lead.first_name || ''} ${lead.last_name || ''}`.trim(),
    96→        previousAssignee: lead.assigned_to
    97→      });
    98→
    99→      console.log(`[ReleaseStaleAssignments] Released lead ${lead.id} (was assigned to ${lead.assigned_to})`);
   100→    }
   101→
   102→    console.log(`[ReleaseStaleAssignments] Complete. Released ${releasedCount} leads.`);
   103→
   104→    return {
   105→      success: true,
   106→      released: releasedCount,
   107→      leads: released
   108→    };
   109→
   110→  } catch (error) {
   111→    console.error('[ReleaseStaleAssignments] Error:', error);
   112→    return { success: false, error: error.message };
   113→  }
   114→}
   115→
   116→module.exports = {
   117→  runReleaseStaleAssignments
   118→};
   119→

<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→ * Daily 0.1% Quality Audit Job
     3→ * Runs every day at 6am AEST
     4→ * Tests all systems and sends report to Slack
     5→ */
     6→
     7→const { runFullAudit, formatSlackReport, formatTextReport } = require('../services/qualityAudit');
     8→const { sendSlackMessage } = require('../services/slack');
     9→
    10→// Get supabase lazily to ensure env vars are loaded
    11→// Use supabaseAdmin to bypass RLS - jobs run without user context
    12→function getSupabase() {
    13→  const { supabaseAdmin, supabase } = require('../config/database');
    14→  return supabaseAdmin || supabase;
    15→}
    16→
    17→/**
    18→ * Run the daily quality audit and send to Slack
    19→ */
    20→async function runDailyQualityAudit() {
    21→  console.log('\n🔍 Starting Daily 0.1% Quality Audit...\n');
    22→
    23→  try {
    24→    // Run all tests
    25→    const results = await runFullAudit();
    26→
    27→    // Log text report to console
    28→    console.log(formatTextReport(results));
    29→
    30→    // Send to Slack
    31→    const slackPayload = formatSlackReport(results);
    32→    await sendSlackMessage(slackPayload);
    33→
    34→    console.log('✅ Audit report sent to Slack');
    35→
    36→    // Store audit results in database for historical tracking
    37→    await storeAuditResults(results);
    38→
    39→    // If score is critical, send additional alert
    40→    if (results.score < 50) {
    41→      await sendSlackMessage({
    42→        text: `🚨 CRITICAL: Quality audit score dropped to ${results.score}/100! Check the report above for details.`
    43→      });
    44→    }
    45→
    46→    return results;
    47→  } catch (error) {
    48→    console.error('❌ Quality audit failed:', error);
    49→
    50→    // Alert on failure
    51→    await sendSlackMessage({
    52→      text: `❌ Daily quality audit failed to run: ${error.message}`
    53→    });
    54→
    55→    throw error;
    56→  }
    57→}
    58→
    59→/**
    60→ * Store audit results for trend tracking
    61→ */
    62→async function storeAuditResults(results) {
    63→  try {
    64→    const supabase = getSupabase();
    65→    if (!supabase) return; // Skip if not configured
    66→
    67→    // Check if table exists, create if not
    68→    const { error } = await supabase.from('quality_audits').insert({
    69→      score: results.score,
    70→      passed: results.summary.passed,
    71→      failed: results.summary.failed,
    72→      warnings: results.summary.warnings,
    73→      duration_ms: results.duration,
    74→      results_json: results,
    75→      created_at: results.timestamp
    76→    });
    77→
    78→    if (error && error.code === '42P01') {
    79→      // Table doesn't exist - that's fine, we'll create it
    80→      console.log('Note: quality_audits table not found, skipping storage');
    81→    } else if (error) {
    82→      console.error('Error storing audit results:', error.message);
    83→    }
    84→  } catch (err) {
    85→    // Non-critical, just log
    86→    console.log('Could not store audit results:', err.message);
    87→  }
    88→}
    89→
    90→/**
    91→ * Get audit history for trending
    92→ */
    93→async function getAuditHistory(days = 7) {
    94→  const supabase = getSupabase();
    95→  if (!supabase) return [];
    96→
    97→  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
    98→
    99→  const { data, error } = await supabase
   100→    .from('quality_audits')
   101→    .select('score, passed, failed, created_at')
   102→    .gte('created_at', since)
   103→    .order('created_at', { ascending: true });
   104→
   105→  if (error) return [];
   106→  return data;
   107→}
   108→
   109→/**
   110→ * Run audit now (for manual triggering)
   111→ */
   112→async function runAuditNow() {
   113→  return runDailyQualityAudit();
   114→}
   115→
   116→module.exports = {
   117→  runDailyQualityAudit,
   118→  runAuditNow,
   119→  getAuditHistory
   120→};
   121→

<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→ * Frequent Audit Jobs
     3→ * 15-min critical checks, hourly extended checks
     4→ * Uses intelligence_alerts for unified alerting
     5→ */
     6→
     7→const {
     8→  runCriticalTests,
     9→  runAPITests,
    10→  runExternalServiceTests,
    11→  AUDIT_CATEGORIES,
    12→  SEVERITY_MAP
    13→} = require('../services/qualityAudit');
    14→const { sendSlackMessage } = require('../services/slack');
    15→
    16→// Get supabase lazily
    17→// Use supabaseAdmin to bypass RLS - jobs run without user context
    18→function getSupabase() {
    19→  const { supabaseAdmin, supabase } = require('../config/database');
    20→  return supabaseAdmin || supabase;
    21→}
    22→
    23→// Alert deduplication window (30 minutes)
    24→const ALERT_COOLDOWN_MS = 30 * 60 * 1000;
    25→
    26→// Track recent alerts to avoid spam
    27→const recentAlerts = new Map();
    28→
    29→/**
    30→ * Run critical checks (every 15 minutes)
    31→ * Tests: Database, Auth, API endpoints
    32→ */
    33→async function runCriticalAudit() {
    34→  console.log('\n[Frequent Audit] Running 15-min critical checks...');
    35→
    36→  try {
    37→    const results = {
    38→      timestamp: new Date().toISOString(),
    39→      type: 'critical',
    40→      categories: {}
    41→    };
    42→
    43→    // Run critical + API tests (fast, essential)
    44→    results.categories.critical = await runCriticalTests();
    45→    results.categories.api = await runAPITests();
    46→
    47→    // Calculate score
    48→    let totalPassed = 0;
    49→    let totalFailed = 0;
    50→
    51→    for (const category of Object.values(results.categories)) {
    52→      totalPassed += category.passed;
    53→      totalFailed += category.failed;
    54→    }
    55→
    56→    results.score = totalPassed + totalFailed > 0
    57→      ? Math.round((totalPassed / (totalPassed + totalFailed)) * 100)
    58→      : 100;
    59→
    60→    // Process failures and create alerts
    61→    await processFailures(results);
    62→
    63→    // Store audit result
    64→    await storeAuditResult(results, 'critical_15min');
    65→
    66→    console.log(`[Frequent Audit] Critical check complete: ${results.score}/100`);
    67→    return results;
    68→
    69→  } catch (error) {
    70→    console.error('[Frequent Audit] Critical check failed:', error);
    71→    await createSystemAlert('Audit System Failure', error.message, 'critical');
    72→    throw error;
    73→  }
    74→}
    75→
    76→/**
    77→ * Run hourly extended checks
    78→ * Tests: External services (Twilio, OpenAI, Deepgram)
    79→ */
    80→async function runHourlyAudit() {
    81→  console.log('\n[Frequent Audit] Running hourly external service checks...');
    82→
    83→  try {
    84→    const results = {
    85→      timestamp: new Date().toISOString(),
    86→      type: 'hourly',
    87→      categories: {}
    88→    };
    89→
    90→    // Run external service tests
    91→    results.categories.external = await runExternalServiceTests();
    92→
    93→    // Calculate score
    94→    const category = results.categories.external;
    95→    results.score = category.passed + category.failed > 0
    96→      ? Math.round((category.passed / (category.passed + category.failed)) * 100)
    97→      : 100;
    98→
    99→    // Process failures
   100→    await processFailures(results);
   101→
   102→    // Store audit result
   103→    await storeAuditResult(results, 'external_hourly');
   104→
   105→    console.log(`[Frequent Audit] Hourly check complete: ${results.score}/100`);
   106→    return results;
   107→
   108→  } catch (error) {
   109→    console.error('[Frequent Audit] Hourly check failed:', error);
   110→    await createSystemAlert('Hourly Audit Failed', error.message, 'high');
   111→    throw error;
   112→  }
   113→}
   114→
   115→/**
   116→ * Process test failures and create alerts
   117→ */
   118→async function processFailures(results) {
   119→  const supabase = getSupabase();
   120→  if (!supabase) return;
   121→
   122→  for (const [categoryKey, category] of Object.entries(results.categories)) {
   123→    const failedTests = category.tests.filter(t => t.status === 'failed');
   124→
   125→    for (const test of failedTests) {
   126→      const alertType = `quality_audit_${categoryKey}`;
   127→      const severity = SEVERITY_MAP[categoryKey] || 'medium';
   128→      const title = `${test.name} Failed`;
   129→
   130→      // Check for duplicate alert - skip if already alerted recently
   131→      const isDuplicate = await checkDuplicateAlert(alertType, title);
   132→      if (isDuplicate) {
   133→        console.log(`[Alert] Skipping duplicate: ${title}`);
   134→        continue; // Skip BOTH alert creation AND Slack notification
   135→      }
   136→
   137→      // Create alert in database
   138→      await createAlert({
   139→        alert_type: alertType,
   140→        severity,
   141→        title,
   142→        description: `Quality audit test "${test.name}" failed: ${test.detail}`,
   143→        evidence: {
   144→          test_name: test.name,
   145→          category: categoryKey,
   146→          error: test.detail,
   147→          audit_type: results.type,
   148→          timestamp: results.timestamp
   149→        },
   150→        suggested_action: getSuggestedAction(categoryKey, test.name)
   151→      });
   152→
   153→      // DISABLED: Slack notifications causing spam - only log to console
   154→      // TODO: Re-enable after fixing cooldown issue
   155→      if (severity === 'critical' || severity === 'high') {
   156→        console.log(`[Alert] Would send Slack: ${title} - ${test.detail}`);
   157→        // await sendSlackMessage({
   158→        //   text: `🚨 Quality Audit Alert: ${title}\n${test.detail}`
   159→        // });
   160→      }
   161→    }
   162→
   163→    // Auto-resolve passed tests that previously failed
   164→    const passedTests = category.tests.filter(t => t.status === 'passed');
   165→    for (const test of passedTests) {
   166→      await autoResolveAlert(`quality_audit_${categoryKey}`, `${test.name} Failed`);
   167→    }
   168→  }
   169→}
   170→
   171→/**
   172→ * Check if a similar alert exists and is recent
   173→ */
   174→async function checkDuplicateAlert(alertType, title) {
   175→  // Check in-memory cache first
   176→  const cacheKey = `${alertType}:${title}`;
   177→  const lastSent = recentAlerts.get(cacheKey);
   178→  if (lastSent && Date.now() - lastSent < ALERT_COOLDOWN_MS) {
   179→    return true;
   180→  }
   181→
   182→  // Check database for unresolved alert
   183→  const supabase = getSupabase();
   184→  if (!supabase) return false;
   185→
   186→  const { data } = await supabase
   187→    .from('intelligence_alerts')
   188→    .select('id, created_at')
   189→    .eq('alert_type', alertType)
   190→    .eq('title', title)
   191→    .is('resolved_at', null)
   192→    .order('created_at', { ascending: false })
   193→    .limit(1);
   194→
   195→  if (data?.length > 0) {
   196→    const alertAge = Date.now() - new Date(data[0].created_at).getTime();
   197→    if (alertAge < ALERT_COOLDOWN_MS) {
   198→      return true;
   199→    }
   200→  }
   201→
   202→  return false;
   203→}
   204→
   205→/**
   206→ * Create an alert in intelligence_alerts table
   207→ */
   208→async function createAlert({ alert_type, severity, title, description, evidence, suggested_action }) {
   209→  const supabase = getSupabase();
   210→  if (!supabase) return;
   211→
   212→  try {
   213→    const { error } = await supabase.from('intelligence_alerts').insert({
   214→      alert_type,
   215→      severity,
   216→      title,
   217→      description,
   218→      evidence,
   219→      suggested_action
   220→    });
   221→
   222→    if (error && error.code !== '42P01') {
   223→      console.error('[Alert] Failed to create alert:', error.message);
   224→    } else {
   225→      // Update cache
   226→      const cacheKey = `${alert_type}:${title}`;
   227→      recentAlerts.set(cacheKey, Date.now());
   228→      console.log(`[Alert] Created: ${title}`);
   229→    }
   230→  } catch (err) {
   231→    console.error('[Alert] Error creating alert:', err.message);
   232→  }
   233→}
   234→
   235→/**
   236→ * Create a system-level alert
   237→ */
   238→async function createSystemAlert(title, error, severity = 'critical') {
   239→  await createAlert({
   240→    alert_type: 'quality_audit_system',
   241→    severity,
   242→    title,
   243→    description: `Quality audit system error: ${error}`,
   244→    evidence: { error, timestamp: new Date().toISOString() },
   245→    suggested_action: 'Check Railway logs and investigate audit system health'
   246→  });
   247→}
   248→
   249→/**
   250→ * Auto-resolve an alert when test passes
   251→ */
   252→async function autoResolveAlert(alertType, title) {
   253→  const supabase = getSupabase();
   254→  if (!supabase) return;
   255→
   256→  try {
   257→    const { data, error } = await supabase
   258→      .from('intelligence_alerts')
   259→      .update({
   260→        resolved_at: new Date().toISOString(),
   261→        resolution_notes: 'Auto-resolved: Test now passing',
   262→        was_true_positive: true
   263→      })
   264→      .eq('alert_type', alertType)
   265→      .eq('title', title)
   266→      .is('resolved_at', null)
   267→      .select();
   268→
   269→    if (data?.length > 0) {
   270→      console.log(`[Alert] Auto-resolved: ${title}`);
   271→      // Clear from cache
   272→      const cacheKey = `${alertType}:${title}`;
   273→      recentAlerts.delete(cacheKey);
   274→    }
   275→  } catch (err) {
   276→    // Non-critical, just log
   277→    console.debug('[Alert] Auto-resolve error:', err.message);
   278→  }
   279→}
   280→
   281→/**
   282→ * Get suggested action based on category and test
   283→ */
   284→function getSuggestedAction(category, testName) {
   285→  const suggestions = {
   286→    critical: {
   287→      default: 'Check Supabase connection and credentials in Railway environment variables',
   288→      'Database Connection': 'Verify SUPABASE_URL and SUPABASE_ANON_KEY are set correctly',
   289→      'Auth System': 'Check SUPABASE_SERVICE_ROLE_KEY is valid'
   290→    },
   291→    api: {
   292→      default: 'Check Railway deployment logs for API errors',
   293→      'Health Endpoint': 'Verify Railway deployment is running and not crashed',
   294→      'Dashboard Stats': 'Check database permissions for leads table'
   295→    },
   296→    external: {
   297→      default: 'Verify external service credentials in Railway environment variables',
   298→      'Twilio API Reachable': 'Check TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN',
   299→      'OpenAI API Reachable': 'Check OPENAI_API_KEY is valid and has credits',
   300→      'Deepgram Configured': 'Check DEEPGRAM_API_KEY is set',
   301→      'Supabase Realtime': 'Verify Supabase realtime is enabled for required tables'
   302→    }
   303→  };
   304→
   305→  const categorySuggestions = suggestions[category] || {};
   306→  return categorySuggestions[testName] || categorySuggestions.default || 'Review test failure and check relevant service configuration';
   307→}
   308→
   309→/**
   310→ * Store audit result for history tracking
   311→ */
   312→async function storeAuditResult(results, auditType) {
   313→  const supabase = getSupabase();
   314→  if (!supabase) return;
   315→
   316→  try {
   317→    let totalPassed = 0;
   318→    let totalFailed = 0;
   319→    let totalWarnings = 0;
   320→
   321→    for (const category of Object.values(results.categories)) {
   322→      totalPassed += category.passed;
   323→      totalFailed += category.failed;
   324→      totalWarnings += category.warnings || 0;
   325→    }
   326→
   327→    await supabase.from('quality_audits').insert({
   328→      score: results.score,
   329→      passed: totalPassed,
   330→      failed: totalFailed,
   331→      warnings: totalWarnings,
   332→      audit_type: auditType,
   333→      results_json: results,
   334→      created_at: results.timestamp
   335→    });
   336→  } catch (err) {
   337→    console.debug('[Audit] Could not store result:', err.message);
   338→  }
   339→}
   340→
   341→module.exports = {
   342→  runCriticalAudit,
   343→  runHourlyAudit,
   344→  createAlert,
   345→  autoResolveAlert
   346→};
   347→

<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 "What's Working" Report
     3→ * Analyzes conversion patterns and sends actionable insights to Slack
     4→ */
     5→
     6→// Use supabaseAdmin to bypass RLS - jobs run without user context
     7→const { supabaseAdmin, supabase } = require('../config/database');
     8→const db = supabaseAdmin || supabase;
     9→const { sendWeeklyReport } = require('../services/slack');
    10→
    11→/**
    12→ * Analyze conversion patterns from the last 30 days
    13→ */
    14→async function analyzePatterns() {
    15→  if (!db) {
    16→    console.log('[WeeklyReport] Database not configured');
    17→    return null;
    18→  }
    19→
    20→  const thirtyDaysAgo = new Date();
    21→  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
    22→
    23→  // Get conversion patterns
    24→  const { data: patterns, error: patternsError } = await db
    25→    .from('conversion_patterns')
    26→    .select('*')
    27→    .gte('created_at', thirtyDaysAgo.toISOString());
    28→
    29→  if (patternsError) {
    30→    console.error('[WeeklyReport] Failed to fetch patterns:', patternsError.message);
    31→    return null;
    32→  }
    33→
    34→  // Get all leads for comparison
    35→  const { data: allLeads, error: leadsError } = await db
    36→    .from('leads')
    37→    .select('id, status, trade, source, score')
    38→    .not('deleted_at', 'is', null);
    39→
    40→  // Get this week's activity
    41→  const sevenDaysAgo = new Date();
    42→  sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
    43→
    44→  const { data: weekComms } = await db
    45→    .from('communications')
    46→    .select('type, direction, metadata, created_at')
    47→    .gte('created_at', sevenDaysAgo.toISOString());
    48→
    49→  // Get effective objection responses
    50→  const { data: effectiveResponses } = await db
    51→    .from('objection_responses')
    52→    .select('*')
    53→    .gt('effectiveness_score', 0.5)
    54→    .order('effectiveness_score', { ascending: false })
    55→    .limit(5);
    56→
    57→  return {
    58→    patterns: patterns || [],
    59→    allLeads: allLeads || [],
    60→    weekComms: weekComms || [],
    61→    effectiveResponses: effectiveResponses || [],
    62→  };
    63→}
    64→
    65→/**
    66→ * Generate insights from the data
    67→ */
    68→function generateInsights(data) {
    69→  const { patterns, allLeads, weekComms, effectiveResponses } = data;
    70→  const insights = [];
    71→  const recommendations = [];
    72→
    73→  // 1. Best performing trades
    74→  const tradeConversions = {};
    75→  const tradeTotal = {};
    76→
    77→  patterns.forEach(p => {
    78→    if (p.lead_trade) {
    79→      tradeConversions[p.lead_trade] = (tradeConversions[p.lead_trade] || 0) + 1;
    80→    }
    81→  });
    82→
    83→  allLeads.forEach(l => {
    84→    if (l.trade) {
    85→      tradeTotal[l.trade] = (tradeTotal[l.trade] || 0) + 1;
    86→    }
    87→  });
    88→
    89→  const tradeRates = Object.keys(tradeConversions).map(trade => ({
    90→    trade,
    91→    conversions: tradeConversions[trade],
    92→    total: tradeTotal[trade] || tradeConversions[trade],
    93→    rate: tradeConversions[trade] / (tradeTotal[trade] || tradeConversions[trade]),
    94→  })).sort((a, b) => b.rate - a.rate);
    95→
    96→  if (tradeRates.length > 0 && tradeRates[0].conversions >= 2) {
    97→    const best = tradeRates[0];
    98→    insights.push({
    99→      type: 'trade',
   100→      icon: '🔧',
   101→      title: `${best.trade} leads convert ${Math.round(best.rate * 100)}% of the time`,
   102→      detail: `${best.conversions} conversions from ${best.total} leads`,
   103→      action: `Prioritizing ${best.trade} leads in your call list`,
   104→    });
   105→    recommendations.push({ type: 'prioritize_trade', trade: best.trade, rate: best.rate });
   106→  }
   107→
   108→  // 2. Best contact times
   109→  const hourConversions = {};
   110→  patterns.forEach(p => {
   111→    if (p.conversion_hour !== null && p.conversion_hour !== undefined) {
   112→      hourConversions[p.conversion_hour] = (hourConversions[p.conversion_hour] || 0) + 1;
   113→    }
   114→  });
   115→
   116→  const bestHours = Object.entries(hourConversions)
   117→    .map(([hour, count]) => ({ hour: parseInt(hour), count }))
   118→    .sort((a, b) => b.count - a.count)
   119→    .slice(0, 3);
   120→
   121→  if (bestHours.length > 0 && bestHours[0].count >= 2) {
   122→    const formatHour = (h) => {
   123→      if (h === 0) return '12am';
   124→      if (h < 12) return `${h}am`;
   125→      if (h === 12) return '12pm';
   126→      return `${h - 12}pm`;
   127→    };
   128→
   129→    const timeRange = bestHours.map(h => formatHour(h.hour)).join(', ');
   130→    insights.push({
   131→      type: 'timing',
   132→      icon: '⏰',
   133→      title: `Best conversion times: ${timeRange}`,
   134→      detail: `${bestHours[0].count} conversions at ${formatHour(bestHours[0].hour)}`,
   135→      action: 'Schedule your most important calls during these hours',
   136→    });
   137→    recommendations.push({ type: 'best_hours', hours: bestHours.map(h => h.hour) });
   138→  }
   139→
   140→  // 3. Winning channel
   141→  const channelConversions = { call: 0, sms: 0, both: 0 };
   142→  patterns.forEach(p => {
   143→    if (p.winning_channel) {
   144→      channelConversions[p.winning_channel] = (channelConversions[p.winning_channel] || 0) + 1;
   145→    }
   146→  });
   147→
   148→  const totalChannelConversions = Object.values(channelConversions).reduce((a, b) => a + b, 0);
   149→  if (totalChannelConversions > 0) {
   150→    const bestChannel = Object.entries(channelConversions)
   151→      .sort((a, b) => b[1] - a[1])[0];
   152→
   153→    if (bestChannel[1] >= 2) {
   154→      const channelLabels = { call: 'Phone calls', sms: 'SMS', both: 'Call + SMS combo' };
   155→      insights.push({
   156→        type: 'channel',
   157→        icon: '📞',
   158→        title: `${channelLabels[bestChannel[0]]} drive ${Math.round(bestChannel[1] / totalChannelConversions * 100)}% of conversions`,
   159→        detail: `${bestChannel[1]} of ${totalChannelConversions} conversions`,
   160→        action: bestChannel[0] === 'both' ? 'Always follow up calls with SMS' : `Focus on ${channelLabels[bestChannel[0]].toLowerCase()}`,
   161→      });
   162→    }
   163→  }
   164→
   165→  // 4. Average touchpoints to convert
   166→  const touchpoints = patterns.map(p => p.total_touchpoints).filter(t => t > 0);
   167→  if (touchpoints.length >= 3) {
   168→    const avgTouchpoints = Math.round(touchpoints.reduce((a, b) => a + b, 0) / touchpoints.length);
   169→    const avgDays = Math.round(
   170→      patterns.map(p => p.days_to_convert).filter(d => d > 0).reduce((a, b) => a + b, 0) /
   171→      patterns.filter(p => p.days_to_convert > 0).length || 0
   172→    );
   173→
   174→    insights.push({
   175→      type: 'persistence',
   176→      icon: '💪',
   177→      title: `Average ${avgTouchpoints} touchpoints over ${avgDays} days to convert`,
   178→      detail: 'Persistence pays off!',
   179→      action: "Don't give up after 1-2 attempts",
   180→    });
   181→  }
   182→
   183→  // 5. Top objection responses
   184→  if (effectiveResponses && effectiveResponses.length > 0) {
   185→    const topResponse = effectiveResponses[0];
   186→    insights.push({
   187→      type: 'objection',
   188→      icon: '🎯',
   189→      title: `"${topResponse.response_text.substring(0, 50)}..." works ${Math.round(topResponse.effectiveness_score * 100)}% of the time`,
   190→      detail: `For "${topResponse.objection_type}" objections`,
   191→      action: 'AI Copilot will suggest this automatically',
   192→    });
   193→  }
   194→
   195→  // 6. This week's activity summary
   196→  const callsThisWeek = weekComms.filter(c => c.type === 'call_outbound').length;
   197→  const smsOutThisWeek = weekComms.filter(c => c.type === 'sms_outbound').length;
   198→  const smsInThisWeek = weekComms.filter(c => c.type === 'sms_inbound').length;
   199→  const conversionsThisWeek = patterns.filter(p => {
   200→    const created = new Date(p.created_at);
   201→    const sevenDaysAgo = new Date();
   202→    sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
   203→    return created >= sevenDaysAgo;
   204→  }).length;
   205→
   206→  return {
   207→    insights,
   208→    recommendations,
   209→    weekStats: {
   210→      calls: callsThisWeek,
   211→      smsOut: smsOutThisWeek,
   212→      smsIn: smsInThisWeek,
   213→      conversions: conversionsThisWeek,
   214→    },
   215→    totalConversions: patterns.length,
   216→  };
   217→}
   218→
   219→/**
   220→ * Store winning patterns in database for call list sorting
   221→ */
   222→async function storeWinningPatterns(recommendations) {
   223→  if (!supabase || recommendations.length === 0) return;
   224→
   225→  // Clear old patterns
   226→  await db
   227→    .from('winning_patterns')
   228→    .update({ is_active: false })
   229→    .eq('is_active', true);
   230→
   231→  // Store new patterns
   232→  for (const rec of recommendations) {
   233→    if (rec.type === 'prioritize_trade') {
   234→      await db
   235→        .from('winning_patterns')
   236→        .insert({
   237→          pattern_type: 'trade',
   238→          pattern_name: `High converting trade: ${rec.trade}`,
   239→          description: `${rec.trade} leads convert at ${Math.round(rec.rate * 100)}%`,
   240→          recommendation: `Prioritize ${rec.trade} leads`,
   241→          criteria: { trade: rec.trade },
   242→          conversion_rate: rec.rate,
   243→          is_active: true,
   244→        });
   245→    }
   246→
   247→    if (rec.type === 'best_hours') {
   248→      await db
   249→        .from('winning_patterns')
   250→        .insert({
   251→          pattern_type: 'timing',
   252→          pattern_name: 'Best contact hours',
   253→          description: `Conversions peak at hours: ${rec.hours.join(', ')}`,
   254→          recommendation: 'Call during these hours',
   255→          criteria: { hours: rec.hours },
   256→          is_active: true,
   257→        });
   258→    }
   259→  }
   260→
   261→  console.log('[WeeklyReport] Stored', recommendations.length, 'winning patterns');
   262→}
   263→
   264→/**
   265→ * Run the weekly report
   266→ */
   267→async function runWeeklyReport() {
   268→  console.log('[WeeklyReport] Starting weekly report...');
   269→
   270→  try {
   271→    // Analyze patterns
   272→    const data = await analyzePatterns();
   273→    if (!data) {
   274→      console.log('[WeeklyReport] No data to analyze');
   275→      return { success: false, reason: 'No data' };
   276→    }
   277→
   278→    // Generate insights
   279→    const report = generateInsights(data);
   280→    console.log('[WeeklyReport] Generated', report.insights.length, 'insights');
   281→
   282→    // Store winning patterns for call list sorting
   283→    await storeWinningPatterns(report.recommendations);
   284→
   285→    // Send to Slack
   286→    const slackResult = await sendWeeklyReport(report);
   287→    if (slackResult.success) {
   288→      console.log('[WeeklyReport] Sent to Slack successfully');
   289→    } else {
   290→      console.log('[WeeklyReport] Slack send failed:', slackResult.error);
   291→    }
   292→
   293→    // Store in ai_insights for the app
   294→    if (supabase) {
   295→      const now = new Date();
   296→      const weekAgo = new Date(now);
   297→      weekAgo.setDate(weekAgo.getDate() - 7);
   298→
   299→      await db
   300→        .from('ai_insights')
   301→        .insert({
   302→          insight_type: 'weekly_summary',
   303→          period_start: weekAgo.toISOString().split('T')[0],
   304→          period_end: now.toISOString().split('T')[0],
   305→          title: `Weekly Report - ${report.insights.length} insights`,
   306→          description: `Based on ${report.totalConversions} conversions over 30 days`,
   307→          data: report,
   308→          recommendations: report.recommendations,
   309→          sample_size: report.totalConversions,
   310→          is_active: true,
   311→        });
   312→    }
   313→
   314→    return { success: true, report };
   315→  } catch (error) {
   316→    console.error('[WeeklyReport] Error:', error);
   317→    return { success: false, error: error.message };
   318→  }
   319→}
   320→
   321→module.exports = {
   322→  runWeeklyReport,
   323→  analyzePatterns,
   324→  generateInsights,
   325→};
   326→

<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 Metrics Job
     3→ * Runs daily to calculate and store activation funnel metrics
     4→ */
     5→
     6→// Use supabaseAdmin to bypass RLS - jobs run without user context
     7→const { supabaseAdmin, supabase } = require('../config/database');
     8→const db = supabaseAdmin || supabase;
     9→const logger = require('../utils/logger');
    10→const {
    11→  WORKER_STAGES,
    12→  CONTRACTOR_STAGES,
    13→  calculateDaysInStage,
    14→  isStuck,
    15→} = require('../services/stageCalculator');
    16→
    17→// Configuration
    18→const AVG_REVENUE_WORKER = parseInt(process.env.ACTIVATION_AVG_REVENUE_WORKER || '500', 10);
    19→const AVG_REVENUE_CONTRACTOR = parseInt(process.env.ACTIVATION_AVG_REVENUE_CONTRACTOR || '3000', 10);
    20→const STUCK_THRESHOLD_DAYS = parseInt(process.env.ACTIVATION_STUCK_THRESHOLD_DAYS || '7', 10);
    21→
    22→/**
    23→ * Calculate and store daily activation metrics
    24→ */
    25→async function runActivationMetrics() {
    26→  if (!db) {
    27→    logger.warn('[ActivationMetrics] Database not configured, skipping');
    28→    return { success: false, error: 'Database not configured' };
    29→  }
    30→
    31→  logger.info('[ActivationMetrics] Starting daily metrics calculation');
    32→  const startTime = Date.now();
    33→  const today = new Date().toISOString().split('T')[0];
    34→  const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
    35→
    36→  try {
    37→    // Fetch all leads with platform data
    38→    const { data: leads, error } = await db
    39→      .from('leads')
    40→      .select('id, platform_stage, platform_data, platform_health_score, metadata, created_at, stage_changed_at')
    41→      .is('deleted_at', null)
    42→      .not('platform_stage', 'is', null);
    43→
    44→    if (error) {
    45→      throw error;
    46→    }
    47→
    48→    // Fetch yesterday's stage transitions to count activations
    49→    let transitions = [];
    50→    try {
    51→      const { data: transData } = await db
    52→        .from('stage_transitions')
    53→        .select('lead_id, from_stage, to_stage, metadata')
    54→        .gte('created_at', `${yesterday}T00:00:00Z`)
    55→        .lt('created_at', `${today}T00:00:00Z`);
    56→      transitions = transData || [];
    57→    } catch (e) {
    58→      logger.warn('[ActivationMetrics] Could not fetch transitions', { error: e.message });
    59→    }
    60→
    61→    // Separate by lead type
    62→    const workers = leads.filter(l => l.metadata?.lead_type === 'worker');
    63→    const contractors = leads.filter(l => l.metadata?.lead_type === 'contractor');
    64→
    65→    // Count transitions by to_stage
    66→    const transitionCounts = {};
    67→    for (const t of transitions) {
    68→      const key = t.to_stage;
    69→      transitionCounts[key] = (transitionCounts[key] || 0) + 1;
    70→    }
    71→
    72→    // Calculate metrics for each lead type
    73→    const workerMetrics = calculateMetricsForType(workers, WORKER_STAGES, 'worker', transitionCounts);
    74→    const contractorMetrics = calculateMetricsForType(contractors, CONTRACTOR_STAGES, 'contractor', transitionCounts);
    75→
    76→    // Store metrics in database
    77→    const metricsToInsert = [];
    78→
    79→    for (const metric of [...workerMetrics, ...contractorMetrics]) {
    80→      metricsToInsert.push({
    81→        date: today,
    82→        lead_type: metric.leadType,
    83→        stage: metric.stage,
    84→        count_at_stage: metric.countAtStage,
    85→        count_activated: metric.countActivated,
    86→        count_stuck: metric.countStuck,
    87→        avg_days_in_stage: metric.avgDaysInStage,
    88→        revenue_at_risk: metric.revenueAtRisk,
    89→      });
    90→    }
    91→
    92→    // Upsert metrics (update if already exists for today)
    93→    if (metricsToInsert.length > 0) {
    94→      const { error: insertError } = await db
    95→        .from('activation_metrics')
    96→        .upsert(metricsToInsert, {
    97→          onConflict: 'date,lead_type,stage',
    98→          ignoreDuplicates: false,
    99→        });
   100→
   101→      if (insertError) {
   102→        // Table might not exist yet
   103→        if (insertError.code === '42P01') {
   104→          logger.warn('[ActivationMetrics] Table not yet created, skipping');
   105→          return { success: false, error: 'Table not created' };
   106→        }
   107→        throw insertError;
   108→      }
   109→    }
   110→
   111→    const duration = Date.now() - startTime;
   112→    const summary = {
   113→      date: today,
   114→      totalLeads: leads.length,
   115→      workers: workers.length,
   116→      contractors: contractors.length,
   117→      metricsStored: metricsToInsert.length,
   118→      duration,
   119→    };
   120→
   121→    logger.info('[ActivationMetrics] Completed', summary);
   122→    return { success: true, ...summary };
   123→  } catch (error) {
   124→    logger.error('[ActivationMetrics] Failed', { error: error.message });
   125→    return { success: false, error: error.message };
   126→  }
   127→}
   128→
   129→/**
   130→ * Calculate metrics for a lead type
   131→ */
   132→function calculateMetricsForType(leads, stages, leadType, transitionCounts = {}) {
   133→  const metrics = [];
   134→
   135→  for (const stage of stages) {
   136→    const leadsAtStage = leads.filter(l => l.platform_stage === stage);
   137→    const countAtStage = leadsAtStage.length;
   138→
   139→    if (countAtStage === 0) {
   140→      metrics.push({
   141→        leadType,
   142→        stage,
   143→        countAtStage: 0,
   144→        countActivated: transitionCounts[stage] || 0,
   145→        countStuck: 0,
   146→        avgDaysInStage: 0,
   147→        revenueAtRisk: 0,
   148→      });
   149→      continue;
   150→    }
   151→
   152→    // Calculate days in stage for each lead
   153→    let totalDays = 0;
   154→    let stuckCount = 0;
   155→
   156→    for (const lead of leadsAtStage) {
   157→      const days = calculateDaysInStage(lead.stage_changed_at, lead.created_at);
   158→      totalDays += days;
   159→      if (isStuck(days, STUCK_THRESHOLD_DAYS)) {
   160→        stuckCount++;
   161→      }
   162→    }
   163→
   164→    const avgDaysInStage = Math.round((totalDays / countAtStage) * 100) / 100;
   165→
   166→    // Calculate revenue at risk from stuck users
   167→    const avgRevenue = leadType === 'contractor' ? AVG_REVENUE_CONTRACTOR : AVG_REVENUE_WORKER;
   168→    const revenueAtRisk = stuckCount * avgRevenue;
   169→
   170→    // Count activated (moved to this stage since yesterday)
   171→    const countActivated = transitionCounts[stage] || 0;
   172→
   173→    metrics.push({
   174→      leadType,
   175→      stage,
   176→      countAtStage,
   177→      countActivated,
   178→      countStuck: stuckCount,
   179→      avgDaysInStage,
   180→      revenueAtRisk,
   181→    });
   182→  }
   183→
   184→  return metrics;
   185→}
   186→
   187→module.exports = {
   188→  runActivationMetrics,
   189→};
   190→

<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→ * Daily Summary Job
     3→ * Generates daily metrics and sends Slack summary
     4→ */
     5→
     6→// Use supabaseAdmin to bypass RLS - jobs run without user context
     7→const { supabaseAdmin, supabase } = require('../config/database');
     8→const db = supabaseAdmin || supabase;
     9→const { sendDailySummary } = require('../services/slack');
    10→
    11→/**
    12→ * Calculate daily metrics
    13→ * @param {Date} date - Date to calculate metrics for
    14→ * @returns {Promise<object>}
    15→ */
    16→async function calculateDailyMetrics(date = new Date()) {
    17→  if (!db) {
    18→    return null;
    19→  }
    20→
    21→  const startOfDay = new Date(date);
    22→  startOfDay.setHours(0, 0, 0, 0);
    23→
    24→  const endOfDay = new Date(date);
    25→  endOfDay.setHours(23, 59, 59, 999);
    26→
    27→  const metrics = {
    28→    date: startOfDay.toISOString().split('T')[0],
    29→    callsMade: 0,
    30→    smsSent: 0,
    31→    smsReceived: 0,
    32→    newLeads: 0,
    33→    conversions: 0,
    34→    hotLeads: 0,
    35→    pendingCallbacks: 0
    36→  };
    37→
    38→  try {
    39→    // Count calls made today
    40→    const { count: callsCount } = await db
    41→      .from('communications')
    42→      .select('*', { count: 'exact', head: true })
    43→      .eq('type', 'call_outbound')
    44→      .gte('created_at', startOfDay.toISOString())
    45→      .lte('created_at', endOfDay.toISOString());
    46→    metrics.callsMade = callsCount || 0;
    47→
    48→    // Count SMS sent today
    49→    const { count: smsSentCount } = await db
    50→      .from('communications')
    51→      .select('*', { count: 'exact', head: true })
    52→      .eq('type', 'sms_outbound')
    53→      .gte('created_at', startOfDay.toISOString())
    54→      .lte('created_at', endOfDay.toISOString());
    55→    metrics.smsSent = smsSentCount || 0;
    56→
    57→    // Count SMS received today
    58→    const { count: smsReceivedCount } = await db
    59→      .from('communications')
    60→      .select('*', { count: 'exact', head: true })
    61→      .eq('type', 'sms_inbound')
    62→      .gte('created_at', startOfDay.toISOString())
    63→      .lte('created_at', endOfDay.toISOString());
    64→    metrics.smsReceived = smsReceivedCount || 0;
    65→
    66→    // Count new leads today
    67→    const { count: newLeadsCount } = await db
    68→      .from('leads')
    69→      .select('*', { count: 'exact', head: true })
    70→      .gte('created_at', startOfDay.toISOString())
    71→      .lte('created_at', endOfDay.toISOString());
    72→    metrics.newLeads = newLeadsCount || 0;
    73→
    74→    // Count conversions today
    75→    const { count: conversionsCount } = await db
    76→      .from('conversions')
    77→      .select('*', { count: 'exact', head: true })
    78→      .gte('created_at', startOfDay.toISOString())
    79→      .lte('created_at', endOfDay.toISOString());
    80→    metrics.conversions = conversionsCount || 0;
    81→
    82→    // Count hot leads (score >= 70)
    83→    const { count: hotCount } = await db
    84→      .from('leads')
    85→      .select('*', { count: 'exact', head: true })
    86→      .gte('score', 70)
    87→      .is('deleted_at', null);
    88→    metrics.hotLeads = hotCount || 0;
    89→
    90→    // Count pending callbacks
    91→    const { count: callbacksCount } = await db
    92→      .from('callbacks')
    93→      .select('*', { count: 'exact', head: true })
    94→      .eq('status', 'pending');
    95→    metrics.pendingCallbacks = callbacksCount || 0;
    96→
    97→  } catch (error) {
    98→    console.error('Metrics calculation error:', error.message);
    99→  }
   100→
   101→  return metrics;
   102→}
   103→
   104→/**
   105→ * Save daily metrics to database
   106→ * @param {object} metrics - Metrics object
   107→ */
   108→async function saveDailyMetrics(metrics) {
   109→  if (!supabase || !metrics) return;
   110→
   111→  try {
   112→    await db
   113→      .from('daily_metrics')
   114→      .upsert({
   115→        date: metrics.date,
   116→        calls_made: metrics.callsMade,
   117→        sms_sent: metrics.smsSent,
   118→        sms_received: metrics.smsReceived,
   119→        new_leads: metrics.newLeads,
   120→        conversions: metrics.conversions,
   121→        hot_leads: metrics.hotLeads,
   122→        pending_callbacks: metrics.pendingCallbacks
   123→      }, {
   124→        onConflict: 'date'
   125→      });
   126→  } catch (error) {
   127→    console.error('Failed to save daily metrics:', error.message);
   128→  }
   129→}
   130→
   131→/**
   132→ * Run daily summary job
   133→ */
   134→async function runDailySummary() {
   135→  console.log('Running daily summary...');
   136→
   137→  const metrics = await calculateDailyMetrics();
   138→
   139→  if (!metrics) {
   140→    console.log('Could not calculate metrics');
   141→    return;
   142→  }
   143→
   144→  // Save to database
   145→  await saveDailyMetrics(metrics);
   146→
   147→  // Send Slack notification
   148→  await sendDailySummary(metrics);
   149→
   150→  console.log('Daily summary complete:', metrics);
   151→  return metrics;
   152→}
   153→
   154→module.exports = {
   155→  calculateDailyMetrics,
   156→  saveDailyMetrics,
   157→  runDailySummary
   158→};
   159→

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