← back to Ventura Corridor

src/jobs/seed_pitches.ts

94 lines

// One-shot — seed a pitch row for every corridor business that doesn't have one yet.
// Pitch type is derived deterministically from NAICS description so /pitches.html
// has a real pipeline to work. No LLM, no outbound — pure DB write.
//
// Run once:  npx tsx src/jobs/seed_pitches.ts

import { pool, query } from '../../db/pool.ts';

// NAICS-description → pitch_type. Order matters: first match wins.
// Covers ~85% of the corridus by descending volume; everything unmatched falls
// to 'office-tenant' (the most generic outreach archetype).
const RULES: [RegExp, string][] = [
  [/restaurant|food service|caterer|bar|tavern|coffee|bakery|patisserie|donut/i,        'restaurant'],
  [/legal services|attorney|law office|notary/i,                                          'law-office'],
  [/dental|dentist|orthodont/i,                                                           'dental-office'],
  [/physician|medical|chiropract|podiatr|optometr|home health|health practitioner|nursing/i, 'medical-clinic'],
  [/mental health|psycholog|therap|counsel/i,                                             'medical-clinic'],
  [/beauty|nail salon|hair|cosmetolog|spa|esthetic|skincare|massage/i,                    'beauty-spa'],
  [/barber/i,                                                                             'salon-spa'],
  [/fitness|gym|yoga|pilates|martial|sports/i,                                            'fitness-studio'],
  [/real estate|property manag|lessor|leasing|realtor/i,                                  'real-estate'],
  [/hotel|motel|inn|lodging|hospitality/i,                                                'hospitality'],
  [/clothing|jewelry|shoes|retail|general merchand|grocery|specialty food|gift|book|wine|liquor/i, 'retail-storefront'],
  [/auto|motor vehicle|repair|car wash|towing|tire/i,                                     'office-tenant'],
  [/dry clean|laundry|tailor|alteration|shoe repair/i,                                    'retail-storefront'],
  [/insurance|financial|bank|credit|invest|account|tax/i,                                  'office-tenant'],
  [/management|consulting|professional|scientific|technical/i,                            'office-tenant'],
  [/personal services|other personal|all other personal/i,                                'office-tenant'],
  [/educational|school|college|tutor|preschool/i,                                         'office-tenant'],
  [/construction|contract|builder/i,                                                      'office-tenant'],
  [/artist|writer|perform|production|motion picture|music|publishing|broadcast/i,         'office-tenant'],
];

function pitchTypeFor(naics: string | null): string {
  if (!naics) return 'office-tenant';
  for (const [re, t] of RULES) if (re.test(naics)) return t;
  return 'office-tenant';
}

// dw_proximity tag — encodes how the business overlaps with DW's catalog/services.
// Used by /pitches.html to colour-code priority. Heuristic on naics + name.
function dwProximityFor(naics: string | null, name: string): string {
  const n = (naics || '').toLowerCase();
  const nm = name.toLowerCase();
  if (/restaurant|coffee|bar|food|bakery/.test(n)) return 'wallcovering-target';
  if (/hotel|motel|spa|salon|beauty/.test(n)) return 'wallcovering-target';
  if (/legal|medical|dental|chiropract|therap/.test(n)) return 'office-tenant';
  if (/real estate|property|lessor/.test(n)) return 'building-owner';
  if (/parking|valet|abm|laz|propark/i.test(nm)) return 'low-priority';
  return 'general';
}

async function main() {
  // Skip merged dupes, LLC/INC/CORP shells (per generate_features convention),
  // anyone already pitched.
  const rows = (await query<{ id: number; name: string; naics: string | null }>(`
    SELECT b.id, b.name, b.raw->>'primary_naics_description' AS naics
    FROM businesses b
    LEFT JOIN pitches p ON p.business_id = b.id
    WHERE b.on_corridor
      AND b.merged_into IS NULL
      AND b.address IS NOT NULL
      AND b.name NOT ILIKE '%LLC' AND b.name NOT ILIKE '%INC' AND b.name NOT ILIKE '%CORP'
      AND length(b.name) BETWEEN 4 AND 60
      AND p.id IS NULL
  `)).rows;

  console.log(`[seed_pitches] candidates without a pitch: ${rows.length.toLocaleString()}`);

  const counts: Record<string, number> = {};
  let n = 0;
  for (const b of rows) {
    const pitch_type = pitchTypeFor(b.naics);
    const dw_proximity = dwProximityFor(b.naics, b.name);
    counts[pitch_type] = (counts[pitch_type] || 0) + 1;
    await query(
      `INSERT INTO pitches (business_id, pitch_type, priority, status, dw_proximity)
       VALUES ($1, $2, 5, 'draft', $3)`,
      [b.id, pitch_type, dw_proximity]
    );
    n++;
    if (n % 1000 === 0) console.log(`[seed_pitches]   ${n.toLocaleString()} written…`);
  }

  console.log(`[seed_pitches] done · ${n.toLocaleString()} pitches created`);
  console.log(`[seed_pitches] by type:`);
  for (const [t, c] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
    console.log(`  ${c.toString().padStart(6)} ${t}`);
  }
  await pool.end();
}

main().catch(e => { console.error('[seed_pitches]', e); process.exit(1); });