← back to kobestarr__outreach-automation

Function bodies 406 total

All specs Real LLM only Function bodies
approveAllPending function · javascript · L210-L222 (13 LOC)
shared/outreach-core/approval-system/approval-manager.js
function approveAllPending(approvedBy = "system") {
  const queue = loadApprovalQueue();
  let approvedCount = 0;

  for (const [category, item] of Object.entries(queue)) {
    if (item.status === "pending") {
      const success = approveTemplate(category, approvedBy);
      if (success) approvedCount++;
    }
  }

  return approvedCount;
}
getQueueItem function · javascript · L229-L232 (4 LOC)
shared/outreach-core/approval-system/approval-manager.js
function getQueueItem(category) {
  const queue = loadApprovalQueue();
  return queue[category] || null;
}
prompt function · javascript · L25-L29 (5 LOC)
shared/outreach-core/approval-system/approve-cli.js
function prompt(question) {
  return new Promise((resolve) => {
    rl.question(question, resolve);
  });
}
wrapText function · javascript · L37-L53 (17 LOC)
shared/outreach-core/approval-system/approve-cli.js
function wrapText(text, width) {
  const words = text.split(' ');
  const lines = [];
  let currentLine = '';

  for (const word of words) {
    if ((currentLine + word).length > width) {
      lines.push(currentLine.trim());
      currentLine = word + ' ';
    } else {
      currentLine += word + ' ';
    }
  }
  if (currentLine) lines.push(currentLine.trim());

  return lines.join('\n');
}
displayApprovalDetail function · javascript · L61-L72 (12 LOC)
shared/outreach-core/approval-system/approve-cli.js
function displayApprovalDetail(item, index, total) {
  console.log(`\n${'─'.repeat(68)}`);
  console.log(`APPROVAL ${index}/${total} - Category: ${item.category || 'unknown'}`);
  console.log(`${'─'.repeat(68)}\n`);
  console.log(`Business: ${item.business.name}`);
  console.log(`Owner: ${item.business.ownerFirstName || 'Unknown'}`);
  console.log(`Category: ${item.business.category || 'unknown'}\n`);
  console.log(`Subject: ${item.email.subject}`);
  console.log(`${'─'.repeat(68)}`);
  console.log(wrapText(item.email.body, 68));
  console.log(`${'─'.repeat(68)}\n`);
}
promptAction function · javascript · L78-L88 (11 LOC)
shared/outreach-core/approval-system/approve-cli.js
async function promptAction() {
  console.log(`Actions:`);
  console.log(`  [a] Approve  - Save template and allow export`);
  console.log(`  [r] Reject   - Block this category from export`);
  console.log(`  [e] Edit     - Modify subject/body before approving`);
  console.log(`  [s] Skip     - Review later`);
  console.log(`  [b] Batch    - Approve all remaining templates`);
  console.log(`  [q] Quit\n`);

  return await prompt('Your choice: ');
}
handleEdit function · javascript · L96-L125 (30 LOC)
shared/outreach-core/approval-system/approve-cli.js
async function handleEdit(category, currentEmail) {
  console.log(`\n=== EDIT EMAIL ===\n`);
  console.log(`Current subject: ${currentEmail.subject}\n`);

  const newSubject = await prompt('New subject (press Enter to keep current): ');
  const subject = newSubject.trim() || currentEmail.subject;

  console.log(`\nCurrent body:\n${currentEmail.body}\n`);
  console.log(`New body (type 'END' on new line when finished):`);

  const bodyLines = [];
  while (true) {
    const line = await prompt('');
    if (line.trim() === 'END') break;
    bodyLines.push(line);
  }

  const body = bodyLines.length > 0 ? bodyLines.join('\n') : currentEmail.body;

  console.log(`\n✓ Email updated\n`);
  console.log(`Subject: ${subject}`);
  console.log(`Body: ${body.substring(0, 100)}...\n`);

  const confirm = await prompt('Confirm approval? (y/n): ');
  if (confirm.toLowerCase() !== 'y') {
    return { subject: currentEmail.subject, body: currentEmail.body };
  }

  return { subject, body };
}
About: code-quality intelligence by Repobility · https://repobility.com
processApprovalItem function · javascript · L135-L178 (44 LOC)
shared/outreach-core/approval-system/approve-cli.js
async function processApprovalItem(category, item, index, total) {
  displayApprovalDetail({ ...item, category }, index, total);

  const action = await promptAction();

  switch (action.toLowerCase()) {
    case 'a': // Approve
      approveTemplate(category, 'cli-user');
      console.log(`\n✓ Template approved for category: ${category}\n`);
      return 'approved';

    case 'r': // Reject
      const reason = await prompt('Rejection reason (optional): ');
      rejectTemplate(category, reason);
      console.log(`\n✗ Template rejected for category: ${category}\n`);
      return 'rejected';

    case 'e': // Edit
      const { subject, body } = await handleEdit(category, item.email);
      editAndApproveTemplate(category, subject, body, 'cli-user');
      console.log(`\n✓ Template edited and approved for category: ${category}\n`);
      return 'approved';

    case 's': // Skip
      console.log(`\n⊙ Skipped ${category}\n`);
      return 'skipped';

    case 'b': // Batch approve al
displaySummary function · javascript · L184-L191 (8 LOC)
shared/outreach-core/approval-system/approve-cli.js
function displaySummary(stats) {
  console.log(`\n=== APPROVAL SUMMARY ===\n`);
  console.log(`✓ Approved: ${stats.approved}`);
  console.log(`✗ Rejected: ${stats.rejected}`);
  console.log(`⊙ Skipped: ${stats.skipped}\n`);
  console.log(`Next steps:`);
  console.log(`  Run: node ksd/local-outreach/orchestrator/utils/resume-approval.js <location> <postcode>\n`);
}
main function · javascript · L196-L228 (33 LOC)
shared/outreach-core/approval-system/approve-cli.js
async function main() {
  const queue = loadApprovalQueue();
  const pending = Object.entries(queue).filter(([cat, item]) => item.status === "pending");

  if (pending.length === 0) {
    console.log("\nNo pending approvals found.\n");
    process.exit(0);
  }

  console.log(`\n=== EMAIL APPROVAL SYSTEM ===\n`);
  console.log(`Found ${pending.length} pending approval${pending.length === 1 ? '' : 's'}:\n`);

  // List all pending approvals
  pending.forEach(([category, item], index) => {
    console.log(`  ${index + 1}. ${category} - ${item.business.name}`);
  });
  console.log();

  const stats = { approved: 0, rejected: 0, skipped: 0 };

  for (let i = 0; i < pending.length; i++) {
    const [category, item] = pending[i];
    const action = await processApprovalItem(category, item, i + 1, pending.length);

    if (action === 'approved') stats.approved++;
    if (action === 'rejected') stats.rejected++;
    if (action === 'skipped') stats.skipped++;
    if (action === 'quit') break;
  
getBusinessType function · javascript · L96-L132 (37 LOC)
shared/outreach-core/content-generation/business-type-helper.js
function getBusinessType(category) {
  if (!category) {
    logger.debug("business-type-helper", "No category provided, using default", {
      category,
    });
    return "local businesses";
  }

  const normalizedCategory = category.toLowerCase().trim();

  // Direct match
  if (BUSINESS_TYPE_MAP[normalizedCategory]) {
    return BUSINESS_TYPE_MAP[normalizedCategory];
  }

  // Partial match (e.g., "dental clinic" → "dental practices")
  for (const [key, value] of Object.entries(BUSINESS_TYPE_MAP)) {
    if (normalizedCategory.includes(key) || key.includes(normalizedCategory)) {
      logger.debug("business-type-helper", "Partial match found", {
        category: normalizedCategory,
        matchedKey: key,
        businessType: value,
      });
      return value;
    }
  }

  // Smart pluralization fallback
  const pluralized = smartPluralize(normalizedCategory);

  logger.debug("business-type-helper", "No match found, using smart pluralization", {
    category: normalizedCategory
smartPluralize function · javascript · L139-L166 (28 LOC)
shared/outreach-core/content-generation/business-type-helper.js
function smartPluralize(word) {
  if (!word) return "local businesses";

  // Already plural
  if (word.endsWith("s") || word.endsWith("es")) {
    return word;
  }

  // Special cases
  if (word.endsWith("y") && !["ay", "ey", "iy", "oy", "uy"].some((v) => word.endsWith(v))) {
    return word.slice(0, -1) + "ies"; // company → companies
  }

  if (word.endsWith("ch") || word.endsWith("sh") || word.endsWith("x") || word.endsWith("z")) {
    return word + "es"; // church → churches
  }

  if (word.endsWith("f")) {
    return word.slice(0, -1) + "ves"; // shelf → shelves
  }

  if (word.endsWith("fe")) {
    return word.slice(0, -2) + "ves"; // knife → knives
  }

  // Default: add 's'
  return word + "s";
}
getBusinessTypeWithArticle function · javascript · L173-L191 (19 LOC)
shared/outreach-core/content-generation/business-type-helper.js
function getBusinessTypeWithArticle(category) {
  const type = getBusinessType(category);

  // Get singular form (remove 's' or 'es')
  let singular = type;
  if (type.endsWith("ies")) {
    singular = type.slice(0, -3) + "y"; // companies → company
  } else if (type.endsWith("es")) {
    singular = type.slice(0, -2); // churches → church
  } else if (type.endsWith("s")) {
    singular = type.slice(0, -1); // dentists → dentist
  }

  // Determine article
  const vowels = ["a", "e", "i", "o", "u"];
  const article = vowels.includes(singular[0].toLowerCase()) ? "an" : "a";

  return `${article} ${singular}`;
}
getCategoryGroup function · javascript · L215-L229 (15 LOC)
shared/outreach-core/content-generation/category-mapper.js
function getCategoryGroup(category) {
  if (!category) return CATEGORY_GROUPS.GENERAL;

  const lowerCategory = category.toLowerCase();

  // Check each group's keywords
  for (const [group, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
    if (keywords.some(keyword => lowerCategory.includes(keyword))) {
      return CATEGORY_GROUPS[group];
    }
  }

  // Default to GENERAL if no match
  return CATEGORY_GROUPS.GENERAL;
}
getCategoryEmailAngles function · javascript · L236-L238 (3 LOC)
shared/outreach-core/content-generation/category-mapper.js
function getCategoryEmailAngles(categoryGroup) {
  return EMAIL_ANGLES[categoryGroup] || EMAIL_ANGLES.GENERAL;
}
Repobility — the code-quality scanner for AI-generated software · https://repobility.com
getCategoryLinkedInAngles function · javascript · L245-L247 (3 LOC)
shared/outreach-core/content-generation/category-mapper.js
function getCategoryLinkedInAngles(categoryGroup) {
  return LINKEDIN_ANGLES[categoryGroup] || LINKEDIN_ANGLES.GENERAL;
}
generateEmailContent function · javascript · L62-L219 (158 LOC)
shared/outreach-core/content-generation/claude-email-generator.js
async function generateEmailContent(params) {
  const {
    barterOpportunity,
    businessName,
    ownerFirstName,
    category,
    location,
    website,
    reviewCount,
    rating,
    competitorName,
    country,
    instagramUrl,
    facebookUrl,
    socialMedia,
    model = "claude-sonnet-4-5-20250929", // Default to Sonnet 4.5 (best balance)
    customPrompt // DEPRECATED: kept for backward compatibility, bypasses micro-offer system
  } = params;

  const apiKey = getCredential("anthropic", "apiKey");

  // Humanize company name for natural tone
  const { humanized: humanizedBusinessName, original: originalBusinessName } = humanizeCompanyName(businessName);

  // Build prompt with humanized company name
  const paramsWithHumanizedName = {
    ...params,
    businessName: humanizedBusinessName,
    originalBusinessName: originalBusinessName
  };

  // Build prompt (use custom prompt if provided for backward compatibility, otherwise use micro-offer system)
  const prompt = cust
buildEmailPrompt function · javascript · L225-L333 (109 LOC)
shared/outreach-core/content-generation/claude-email-generator.js
function buildEmailPrompt(params) {
  const {
    barterOpportunity,
    businessName,
    ownerFirstName,
    category,
    location,
    reviewCount,
    rating,
    competitorName,
    website,
    country // optional country override
  } = params;

  // 1. Determine category group
  const categoryGroup = getCategoryGroup(category);

  // 2. Compute observation signals
  const signals = computeObservationSignals({
    reviewCount,
    rating,
    website,
    instagramUrl: params.instagramUrl,
    facebookUrl: params.facebookUrl,
    socialMedia: params.socialMedia
  });

  // 3. Select primary signal (most important)
  const primarySignal = selectPrimarySignal(signals);
  const signalHook = primarySignal ? getSignalHook(primarySignal) : "growing your business";

  // 4. Get category angles
  const angles = getCategoryEmailAngles(categoryGroup);

  // 5. Select primary angle (use first angle as default)
  const primaryAngle = angles[0] || "optimizing your customer acquisition";

  /
parseEmailContent function · javascript · L338-L381 (44 LOC)
shared/outreach-core/content-generation/claude-email-generator.js
function parseEmailContent(content) {
  const lines = content.split("\n");
  let subject = "";
  let bodyLines = [];
  let inBody = false;
  let foundSubject = false;

  for (const line of lines) {
    if (line.toLowerCase().startsWith("subject:")) {
      subject = line.replace(/^subject:\s*/i, "").trim();
      foundSubject = true;
      continue; // Skip adding this line to body
    } else if (line.toLowerCase().startsWith("body:")) {
      inBody = true;
      const bodyStart = line.replace(/^body:\s*/i, "").trim();
      if (bodyStart) {
        bodyLines.push(bodyStart);
      }
      continue;
    } else if (inBody) {
      bodyLines.push(line);
    } else if (!foundSubject && !line.trim()) {
      // Skip empty lines before we've found the subject
      continue;
    }
  }

  const body = bodyLines.join("\n").trim();

  // Fallback: if no subject/body markers, use first line as subject, rest as body
  if (!subject && !body) {
    const parts = content.split("\n\n");
    subject
generateEmailSequence function · javascript · L386-L482 (97 LOC)
shared/outreach-core/content-generation/claude-email-generator.js
async function generateEmailSequence(businessData, sequenceConfig = {}) {
  const emails = [];

  // Email 1: Initial outreach (uses micro-offer system with observation hook)
  const email1 = await generateEmailContent({
    ...businessData,
    customPrompt: sequenceConfig.email1Prompt || undefined
  });
  email1.delayDays = 0;
  emails.push(email1);

  // Email 2: Follow-up with different angle (3-4 days later)
  const email2Prompt = `Write a follow-up email for ${businessData.businessName} (a ${businessData.category || 'business'}).

CONTEXT: This is Email 2 of 4. They didn't respond to the first email about your original observation.

APPROACH: Use a DIFFERENT angle from the first email. Choose one:
- If first email was about social media → talk about reviews or rebooking
- If first email was about reviews → talk about online booking or automation
- If first email was about rebooking → talk about social proof or visibility

Keep it brief, conversational, and reference that you "sen
generateConnectionNote function · javascript · L53-L158 (106 LOC)
shared/outreach-core/content-generation/claude-linkedin-generator.js
async function generateConnectionNote(params) {
  const {
    businessName,
    ownerFirstName,
    category,
    location,
    model = "claude-sonnet-4-5-20250929"
  } = params;

  const apiKey = getCredential("anthropic", "apiKey");

  // Get category group for context
  const categoryGroup = getCategoryGroup(category);

  const prompt = `Write a LinkedIn connection request note for a UK local business owner.

Business: ${businessName}
Owner: ${ownerFirstName}
Category: ${category} (${categoryGroup})
Location: ${location}

Requirements:
- Under 300 characters (LinkedIn limit)
- Reference their business and location as context
- NO sales pitch - just friendly professional context
- Use casual tone: "I love" not "I'm intrigued by"
- Business name ONLY (no location suffix)
- Example: "Hi ${ownerFirstName}, I work with ${category} businesses in ${location} and love what you're doing with ${businessName}. Would be great to connect."

Output just the connection note text (no labels or form
generateLinkedInMessage function · javascript · L167-L288 (122 LOC)
shared/outreach-core/content-generation/claude-linkedin-generator.js
async function generateLinkedInMessage(params) {
  const {
    businessName,
    ownerFirstName,
    category,
    location,
    emailSubject,
    emailBody,
    model = "claude-sonnet-4-5-20250929"
  } = params;

  const apiKey = getCredential("anthropic", "apiKey");

  // Get category group and LinkedIn angles (different from email)
  const categoryGroup = getCategoryGroup(category);
  const linkedInAngles = getCategoryLinkedInAngles(categoryGroup);
  const primaryAngle = linkedInAngles[0] || "growing your business";

  const prompt = `Write a LinkedIn message (post-connection) for a UK local business owner.

Business: ${businessName}
Owner: ${ownerFirstName}
Category: ${category} (${categoryGroup})
Location: ${location}
LinkedIn Angle: ${primaryAngle} (use this as context, different from email)

Email Context (DO NOT REPEAT):
Subject: ${emailSubject}
Body: ${emailBody}

Requirements:
- Start with: "Hi ${ownerFirstName}, I don't know if you saw my email so I thought I'd try here."
- 
stripLegalSuffixes function · javascript · L134-L153 (20 LOC)
shared/outreach-core/content-generation/company-name-humanizer.js
function stripLegalSuffixes(name) {
  let cleaned = name.trim();

  // Build regex pattern for legal suffixes
  // Match at end of string or followed by comma/period
  const pattern = new RegExp(
    `\\s+(${LEGAL_SUFFIXES.join('|')})(?:[\\s,.]*)$`,
    'gi'
  );

  // Keep stripping until no more matches (handles "Co., Ltd." etc.)
  let previousName;
  do {
    previousName = cleaned;
    cleaned = cleaned.replace(pattern, '');
    cleaned = cleaned.trim().replace(/[,.]$/, '').trim();
  } while (cleaned !== previousName && cleaned.length > 0);

  return cleaned;
}
Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
stripGenericDescriptors function · javascript · L162-L190 (29 LOC)
shared/outreach-core/content-generation/company-name-humanizer.js
function stripGenericDescriptors(name, original) {
  let cleaned = name.trim();
  const words = cleaned.split(/\s+/);

  // Don't strip if name is already short
  if (words.length <= 1) {
    return cleaned;
  }

  // Check last word
  const lastWord = words[words.length - 1];

  // If last word is brand-core descriptor, keep it
  if (BRAND_CORE_DESCRIPTORS.some(desc => desc.toLowerCase() === lastWord.toLowerCase())) {
    return cleaned;
  }

  // If last word is generic descriptor, strip it
  if (GENERIC_DESCRIPTORS.some(desc => desc.toLowerCase() === lastWord.toLowerCase())) {
    const withoutLast = words.slice(0, -1).join(' ');

    // Strip if result is not empty and is a valid brand name
    if (withoutLast.length > 0) {
      return withoutLast;
    }
  }

  return cleaned;
}
stripLocation function · javascript · L197-L209 (13 LOC)
shared/outreach-core/content-generation/company-name-humanizer.js
function stripLocation(name) {
  let cleaned = name;

  // Apply all location patterns
  for (const pattern of UK_LOCATION_PATTERNS) {
    cleaned = cleaned.replace(pattern, '');
  }

  // Clean up extra spaces and trailing punctuation
  cleaned = cleaned.trim().replace(/\s+/g, ' ').replace(/[,.-]+$/, '').trim();

  return cleaned;
}
humanizeCompanyName function · javascript · L216-L259 (44 LOC)
shared/outreach-core/content-generation/company-name-humanizer.js
function humanizeCompanyName(companyName) {
  if (!companyName || typeof companyName !== 'string') {
    return {
      humanized: companyName || '',
      original: companyName || ''
    };
  }

  const original = companyName.trim();
  let humanized = original;

  // Step 1: Strip location names
  humanized = stripLocation(humanized);

  // Step 2: Strip legal entity suffixes
  humanized = stripLegalSuffixes(humanized);

  // Step 3: Strip generic descriptors (but keep brand-core words)
  humanized = stripGenericDescriptors(humanized, original);

  // Step 4: Final cleanup
  humanized = humanized.trim();

  // Validation: Don't return empty string
  if (humanized.length === 0) {
    logger.warn('company-name-humanizer', 'Humanization resulted in empty string', {
      original
    });
    return {
      humanized: original,
      original
    };
  }

  logger.debug('company-name-humanizer', 'Humanized company name', {
    original,
    humanized
  });

  return {
    humanized,
    or
getShortNameForTeam function · javascript · L274-L362 (89 LOC)
shared/outreach-core/content-generation/company-name-humanizer.js
function getShortNameForTeam(companyName) {
  if (!companyName || typeof companyName !== 'string') {
    return 'Team';
  }

  // First humanize to remove location, legal suffixes, etc.
  const { humanized } = humanizeCompanyName(companyName);

  // Split into words
  const words = humanized.split(/\s+/).filter(w => w.length > 0);

  if (words.length === 0) {
    return 'Team';
  }

  // Skip leading articles (The, A, An)
  const articles = ['the', 'a', 'an'];
  let startIndex = 0;
  if (words.length > 1 && articles.includes(words[0].toLowerCase())) {
    startIndex = 1;
  }

  // Business type keywords that should be kept as second word
  const businessTypes = [
    'cafe', 'coffee', 'restaurant', 'bar', 'pub', 'bistro', 'grill', 'kitchen',
    'gym', 'fitness', 'yoga', 'pilates', 'wellness', 'health', 'spa',
    'dental', 'dentist', 'clinic', 'surgery', 'medical', 'pharmacy',
    'salon', 'barber', 'hair', 'beauty', 'nails', 'spa',
    'bakery', 'patisserie', 'deli', 'butcher', 'groc
runTests function · javascript · L368-L409 (42 LOC)
shared/outreach-core/content-generation/company-name-humanizer.js
function runTests() {
  const testCases = [
    { input: 'KissDental Bramhall', expected: 'KissDental' },
    { input: 'Kobestarr Digital Limited', expected: 'Kobestarr Digital' },
    { input: 'Apple Inc.', expected: 'Apple' },
    { input: 'Shell plc', expected: 'Shell' },
    { input: 'Samsung Electronics Co., Ltd.', expected: 'Samsung' },
    { input: 'Starbucks Coffee Company', expected: 'Starbucks Coffee' },
    { input: 'Microsoft Corporation', expected: 'Microsoft' },
    { input: 'Amazon.com, Inc.', expected: 'Amazon.com' },
    { input: 'The Hair Salon London', expected: 'The Hair Salon' },
    { input: 'Joe\'s Pizza Manchester', expected: 'Joe\'s Pizza' },
    { input: 'Acme Ltd', expected: 'Acme' },
    { input: 'XYZ Holdings plc', expected: 'XYZ' },
    { input: 'ABC Solutions Limited', expected: 'ABC Solutions' }
  ];

  const results = testCases.map(test => {
    const result = humanizeCompanyName(test.input);
    const passed = result.humanized === test.expected;

    r
detectCountryFromLocation function · javascript · L59-L147 (89 LOC)
shared/outreach-core/content-generation/currency-localization.js
function detectCountryFromLocation(location) {
  if (!location || typeof location !== 'string') return "UK"; // Default to UK

  const locationLower = location.toLowerCase();

  // UK: Check for postcode patterns (e.g. SK7, M1, SW1A, etc.)
  // UK postcodes: 1-2 letters, 1-2 digits, optional space, digit, 2 letters
  if (/\b[A-Z]{1,2}\d{1,2}\s?\d?[A-Z]{0,2}\b/i.test(location)) {
    return "UK";
  }

  // UK: Common UK place indicators
  if (locationLower.includes("uk") ||
      locationLower.includes("united kingdom") ||
      locationLower.includes("england") ||
      locationLower.includes("scotland") ||
      locationLower.includes("wales") ||
      locationLower.includes("northern ireland")) {
    return "UK";
  }

  // US: Check for state abbreviations (2 capital letters)
  // Look for patterns like "NY", "CA", "TX" etc in context
  const usStatePattern = /\b(AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD
getCurrencyForLocation function · javascript · L157-L163 (7 LOC)
shared/outreach-core/content-generation/currency-localization.js
function getCurrencyForLocation(location, country = null) {
  // Use provided country or detect from location
  const detectedCountry = country || detectCountryFromLocation(location);

  // Return currency object, defaulting to UK if country not found
  return CURRENCY_MAP[detectedCountry] || CURRENCY_MAP.UK;
}
formatPrice function · javascript · L173-L175 (3 LOC)
shared/outreach-core/content-generation/currency-localization.js
function formatPrice(amount, currency) {
  return `${currency.symbol}${amount}`;
}
Citation: Repobility (2026). State of AI-Generated Code. https://repobility.com/research/
getAvailableCountries function · javascript · L183-L185 (3 LOC)
shared/outreach-core/content-generation/currency-localization.js
function getAvailableCountries() {
  return Object.keys(CURRENCY_MAP);
}
isNearby function · javascript · L74-L79 (6 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function isNearby(postcode) {
  if (!postcode) return false;

  const postcodePrefix = postcode.substring(0, 3).toUpperCase().trim();
  return NEARBY_POSTCODES.includes(postcodePrefix);
}
getLocalIntro function · javascript · L86-L92 (7 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getLocalIntro(postcode) {
  if (isNearby(postcode)) {
    return "I'm Kobi, a digital marketing consultant based in Poynton, so pretty close to you!";
  } else {
    return "I'm Kobi, a digital marketing consultant working with local businesses across the UK.";
  }
}
getObservationSignal function · javascript · L101-L126 (26 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getObservationSignal(business) {
  const signals = computeObservationSignals(business);
  const primarySignal = selectPrimarySignal(signals);

  // Map signals to natural observations
  const observations = {
    tradesLeadGen: "know how much sites like Checkatrade and MyBuilder can eat into your margins — most tradespeople I speak to are spending £1,000-2,000 a year just to compete for leads in their own postcode",
    lowReviews: "saw you're building up your online reputation",
    noWebsite: "noticed you don't have a website yet",
    poorWebsite: "thought your website could use a refresh",
    noSocialMedia: "saw you could use help with social media",
    lowRating: "noticed you could improve your online presence",
    highReviews: "saw you've built up a solid reputation online"
  };

  const observationText = observations[primarySignal] || "thought I'd reach out";

  logger.debug('email-merge-variables', 'Generated observation signal', {
    business: business.name,
    p
getMeetingOption function · javascript · L133-L139 (7 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getMeetingOption(postcode) {
  if (isNearby(postcode)) {
    return "meet in person if that's easier";
  } else {
    return "have a chat";
  }
}
getMicroOfferPrice function · javascript · L146-L168 (23 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getMicroOfferPrice(business) {
  const tier = business.assignedOfferTier || 'tier5';
  const multiplier = TIER_MULTIPLIERS[tier];

  // Get currency and base price for location
  const currency = getCurrencyForLocation(business.location || 'UK');
  const basePrice = currency.microOffer; // 97 for UK, 127 for US, etc.

  // Calculate tiered price
  const finalPrice = Math.round(basePrice * multiplier);
  const formattedPrice = `${currency.symbol}${finalPrice}`;

  logger.debug('email-merge-variables', 'Calculated tiered pricing', {
    business: business.name,
    tier,
    multiplier,
    basePrice,
    finalPrice: formattedPrice,
    country: currency.country
  });

  return formattedPrice;
}
getMultiOwnerNote function · javascript · L176-L209 (34 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getMultiOwnerNote(business) {
  // Check if business has multiple owners
  if (!business.owners || business.owners.length <= 1) {
    return ""; // Single owner or no owner data
  }

  // Get names of other owners (excluding first one we're addressing)
  const otherOwnerNames = business.owners
    .slice(1) // Skip first owner (they're in {{firstName}})
    .map(o => o.firstName)
    .filter(name => name && name.trim()) // Remove empty/null names
    .slice(0, 5); // Cap at 5 people max

  if (otherOwnerNames.length === 0) {
    return ""; // No valid other names found
  }

  // Format with commas and Oxford comma
  let otherOwners;
  if (otherOwnerNames.length === 1) {
    otherOwners = otherOwnerNames[0];
  } else if (otherOwnerNames.length === 2) {
    otherOwners = otherOwnerNames.join(' and ');
  } else {
    // 3+ names: "Sarah, John, and Mike" (Oxford comma)
    const lastIndex = otherOwnerNames.length - 1;
    otherOwners = otherOwnerNames.slice(0, lastIndex).join(', '
getNoNameNote function · javascript · L217-L225 (9 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getNoNameNote(business) {
  // Only show note if we used fallback name (no owner names found)
  if (!business.usedFallbackName) {
    return ""; // Real owner name found
  }

  // Note: trailing space ensures natural flow into {{localIntro}}
  return "I couldn't find a direct contact name for your business! ";
}
About: code-quality intelligence by Repobility · https://repobility.com
getValidFirstName function · javascript · L239-L271 (33 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getValidFirstName(business) {
  // Option 1: Use existing firstName if valid
  if (business.ownerFirstName && isValidPersonName(business.ownerFirstName)) {
    return business.ownerFirstName;
  }

  // Option 2: Extract from email (CRITICAL FIX for "[email protected]" → "Derek")
  const email = business.ownerEmail || business.email; // Handle both field names
  if (email && email.includes('@')) {
    const extractedName = extractNameFromEmail(email);
    if (extractedName) {
      const firstName = extractedName.split(' ')[0];

      logger.info('email-merge-variables', 'Extracted firstName from email', {
        business: business.name || business.businessName,
        email: email,
        extractedFirstName: firstName,
        originalFirstName: business.ownerFirstName
      });

      return firstName;
    }
  }

  // Option 3: Final fallback
  logger.warn('email-merge-variables', 'Using fallback firstName "there"', {
    business: business.name || business.bu
getAllMergeVariables function · javascript · L278-L311 (34 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getAllMergeVariables(business) {
  const postcode = business.postcode || '';

  const mergeVariables = {
    // Core business data
    firstName: getValidFirstName(business),  // CHANGED: Now uses intelligent fallback
    lastName: business.ownerLastName || '',
    companyName: business.businessName || business.name,
    location: business.location || '',
    businessType: getBusinessType(business.category),

    // Dynamic variables
    localIntro: getLocalIntro(postcode),
    observationSignal: getObservationSignal(business),
    meetingOption: getMeetingOption(postcode),
    microOfferPrice: getMicroOfferPrice(business),
    multiOwnerNote: getMultiOwnerNote(business),
    noNameNote: getNoNameNote(business),

    // Additional context
    isNearby: isNearby(postcode),
    tier: business.assignedOfferTier || 'tier5',
    postcode: postcode
  };

  logger.info('email-merge-variables', 'Generated merge variables', {
    business: business.name,
    isNearby: mergeVariables.is
addNearbyPostcode function · javascript · L318-L324 (7 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function addNearbyPostcode(postcode) {
  const prefix = postcode.substring(0, 3).toUpperCase().trim();
  if (!NEARBY_POSTCODES.includes(prefix)) {
    NEARBY_POSTCODES.push(prefix);
    logger.info('email-merge-variables', 'Added nearby postcode', { postcode: prefix });
  }
}
getNearbyPostcodes function · javascript · L330-L332 (3 LOC)
shared/outreach-core/content-generation/email-merge-variables.js
function getNearbyPostcodes() {
  return [...NEARBY_POSTCODES];
}
generateEmailContent function · javascript · L56-L202 (147 LOC)
shared/outreach-core/content-generation/gpt-email-generator.js
async function generateEmailContent(params) {
  const {
    barterOpportunity,
    businessName,
    ownerFirstName,
    category,
    location,
    website,
    reviewCount,
    rating,
    competitorName,
    country,
    instagramUrl,
    facebookUrl,
    socialMedia,
    customPrompt // DEPRECATED: kept for backward compatibility, bypasses micro-offer system
  } = params;

  const apiKey = getCredential("openai", "apiKey");

  // Humanize company name for natural tone
  const { humanized: humanizedBusinessName, original: originalBusinessName } = humanizeCompanyName(businessName);

  // Build prompt with humanized company name
  const paramsWithHumanizedName = {
    ...params,
    businessName: humanizedBusinessName,
    originalBusinessName: originalBusinessName
  };

  // Build prompt (use custom prompt if provided for backward compatibility, otherwise use micro-offer system)
  const prompt = customPrompt || buildEmailPrompt(paramsWithHumanizedName);
  
  return new Promise((resol
buildEmailPrompt function · javascript · L208-L309 (102 LOC)
shared/outreach-core/content-generation/gpt-email-generator.js
function buildEmailPrompt(params) {
  const {
    barterOpportunity,
    businessName,
    ownerFirstName,
    category,
    location,
    reviewCount,
    rating,
    competitorName,
    website,
    country // optional country override
  } = params;

  // 1. Determine category group
  const categoryGroup = getCategoryGroup(category);

  // 2. Compute observation signals
  const signals = computeObservationSignals({
    reviewCount,
    rating,
    website,
    instagramUrl: params.instagramUrl,
    facebookUrl: params.facebookUrl,
    socialMedia: params.socialMedia
  });

  // 3. Select primary signal (most important)
  const primarySignal = selectPrimarySignal(signals);
  const signalHook = primarySignal ? getSignalHook(primarySignal) : "growing your business";

  // 4. Get category angles
  const angles = getCategoryEmailAngles(categoryGroup);

  // 5. Select primary angle (use first angle as default)
  // NOTE: Future enhancement - match angle to signal intelligently
  // This re
parseEmailContent function · javascript · L314-L342 (29 LOC)
shared/outreach-core/content-generation/gpt-email-generator.js
function parseEmailContent(content) {
  const lines = content.split("\n");
  let subject = "";
  let body = "";
  let inBody = false;
  
  for (const line of lines) {
    if (line.toLowerCase().startsWith("subject:")) {
      subject = line.replace(/^subject:\s*/i, "").trim();
    } else if (line.toLowerCase().startsWith("body:")) {
      inBody = true;
      body = line.replace(/^body:\s*/i, "").trim();
    } else if (inBody) {
      body += "\n" + line.trim();
    }
  }
  
  // Fallback: if no subject/body markers, use first line as subject, rest as body
  if (!subject && !body) {
    const parts = content.split("\n\n");
    subject = parts[0] || "";
    body = parts.slice(1).join("\n\n") || content;
  }
  
  return {
    subject: subject || "Quick question about your business",
    body: body || content
  };
}
generateEmailSequence function · javascript · L347-L375 (29 LOC)
shared/outreach-core/content-generation/gpt-email-generator.js
async function generateEmailSequence(businessData, sequenceConfig = {}) {
  const emails = [];
  
  // Email 1: Initial outreach
  emails.push(await generateEmailContent({
    ...businessData,
    customPrompt: sequenceConfig.email1Prompt || undefined
  }));
  
  // Email 2: Follow-up (if no response)
  emails.push(await generateEmailContent({
    ...businessData,
    customPrompt: sequenceConfig.email2Prompt || `Write a follow-up email (Email 2 of 4) for ${businessData.businessName}. This is a gentle follow-up with a helpful tip or resource. Keep it brief and low-pressure.`
  }));
  
  // Email 3: Case study/value prop
  emails.push(await generateEmailContent({
    ...businessData,
    customPrompt: sequenceConfig.email3Prompt || `Write a follow-up email (Email 3 of 4) for ${businessData.businessName}. Share a brief case study or specific value proposition. Still low-pressure.`
  }));
  
  // Email 4: Final touch
  emails.push(await generateEmailContent({
    ...businessData,
    cust
Repobility — the code-quality scanner for AI-generated software · https://repobility.com
generateConnectionNote function · javascript · L50-L98 (49 LOC)
shared/outreach-core/content-generation/gpt-linkedin-generator.js
async function generateConnectionNote(params) {
  const {
    ownerFirstName,
    businessName,
    category,
    location,
    linkedInTitle,
    emailAngleUsed, // Optional: angle used in email to avoid repetition
    customPrompt // DEPRECATED
  } = params;

  const apiKey = getCredential("openai", "apiKey");

  if (customPrompt) {
    // Backward compatibility: use custom prompt if provided
    return callGPT4(customPrompt, apiKey);
  }

  // 1. Determine category group
  const categoryGroup = getCategoryGroup(category);

  // 2. Get LinkedIn angles (different from email angles)
  const linkedInAngles = getCategoryLinkedInAngles(categoryGroup);

  // 3. Select angle (use first LinkedIn angle, which differs from email angles)
  const selectedAngle = linkedInAngles[0] || "building a stronger local presence";

  const prompt = `Write a LinkedIn connection request note for a UK local business owner.

Name: ${ownerFirstName}
Business: ${businessName}
Category: ${category} (${categoryGro
generateLinkedInMessage function · javascript · L109-L160 (52 LOC)
shared/outreach-core/content-generation/gpt-linkedin-generator.js
async function generateLinkedInMessage(params) {
  const {
    ownerFirstName,
    businessName,
    category,
    location,
    emailSubject,
    emailPrimaryHook, // What observation/hook was used in email
    customPrompt // DEPRECATED
  } = params;

  const apiKey = getCredential("openai", "apiKey");

  if (customPrompt) {
    // Backward compatibility: use custom prompt if provided
    return callGPT4(customPrompt, apiKey);
  }

  // 1. Determine category group
  const categoryGroup = getCategoryGroup(category);

  // 2. Get LinkedIn angles (different from email angles)
  const linkedInAngles = getCategoryLinkedInAngles(categoryGroup);

  // 3. Select angle that's different from email hook
  // Use second LinkedIn angle if available, otherwise first
  const selectedAngle = linkedInAngles[1] || linkedInAngles[0] || "growing your customer base";

  const prompt = `Write a LinkedIn first message for ${ownerFirstName} at ${businessName} in ${location}.

Context:
- They just accepted y
callGPT4 function · javascript · L165-L229 (65 LOC)
shared/outreach-core/content-generation/gpt-linkedin-generator.js
function callGPT4(prompt, apiKey) {
  return new Promise((resolve, reject) => {
    const postData = JSON.stringify({
      model: "gpt-4",
      messages: [
        {
          role: "system",
          content: LINKEDIN_SYSTEM_PROMPT
        },
        {
          role: "user",
          content: prompt
        }
      ],
      temperature: 0.75, // Slightly higher for spontaneity
      max_tokens: 800 // Increased for longer DM sequences
    });
    
    const options = {
      hostname: OPENAI_BASE_URL,
      path: "/v1/chat/completions",
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${apiKey}`,
        "Content-Length": Buffer.byteLength(postData)
      }
    };
    
    const req = https.request(options, (res) => {
      let data = "";
      
      res.on("data", (chunk) => {
        data += chunk;
      });
      
      res.on("end", () => {
        try {
          const result = JSON.parse(data);
          
 
‹ prevpage 4 / 9next ›