/** * Territory Manager * * Controls: which city, which industry, on which day. * Prevents: re-searching same city+industry within 30 days. * Tracks: daily progression, checkpoint for resume. * * Daily flow: * 1. Load current position (city + industry) * 2. Check if already searched recently (30-day window) * 3. If fresh → search → advance pointer * 4. If stale → skip to next fresh combo * 5. Save position for tomorrow */ import { getSupabaseClient } from "../../shared/supabase/client"; import { logger } from "../../shared/utils/logger"; export interface TerritoryUnit { territoryId: string; country: string; countryCode: string; city: string; industry: string; timezone: string; tier: number; } export interface TerritoryPosition { countryCode: string; cityIndex: number; industryIndex: number; } // Industries to search (per territory cycle) const INDUSTRY_LIST = [ "dental", "medical", "veterinary", "legal", "salon", "spa", // service businesses (AI Receptionist) "ecommerce", "saas", "retail", "hospitality", // support-heavy (AI Support) "manufacturing", "logistics", "finance", "healthcare", // data-heavy (AI Data Processing) "technology", "consulting", "recruitment", "insurance", // sales-heavy (AI Sales Automation) ]; /** * Get the next territory unit to search today. * Respects 30-day cooldown and daily quota. */ export async function getNextTerritory(quota: number): Promise { const db = getSupabaseClient(); const units: TerritoryUnit[] = []; // Load current position from system_config const { data: configData } = await db .from("system_config") .select("value") .eq("key", "current_territory") .single(); const position: TerritoryPosition = configData?.value ?? { countryCode: "US", cityIndex: 0, industryIndex: 0, }; // Load all cities ordered by tier (major cities first) const { data: cities } = await db .from("territory_grid") .select("*") .eq("is_active", true) .order("tier", { ascending: true }) .order("city", { ascending: true }); if (!cities?.length) { logger.error("No active territories found in territory_grid"); return []; } // Start from current position let cityIdx = position.cityIndex; let industryIdx = position.industryIndex; let searched = 0; // Keep finding fresh territory units until quota is met // (estimated: each unit produces ~2-3 qualified leads) const unitsNeeded = Math.ceil(quota / 2); let attempts = 0; const maxAttempts = cities.length * INDUSTRY_LIST.length; // prevent infinite loop while (units.length < unitsNeeded && attempts < maxAttempts) { attempts++; const city = cities[cityIdx % cities.length]; const industry = INDUSTRY_LIST[industryIdx % INDUSTRY_LIST.length]; // Check 30-day cooldown const isFresh = await isTerritoryFresh(city.id, industry); if (isFresh) { units.push({ territoryId: city.id, country: city.country, countryCode: city.country_code, city: city.city, industry, timezone: city.timezone ?? "UTC", tier: city.tier, }); } // Advance: next industry, or wrap to next city industryIdx++; if (industryIdx >= INDUSTRY_LIST.length) { industryIdx = 0; cityIdx++; } } // Save new position for tomorrow await db.from("system_config").upsert({ key: "current_territory", value: { countryCode: cities[cityIdx % cities.length]?.country_code ?? "US", cityIndex: cityIdx % cities.length, industryIndex: industryIdx % INDUSTRY_LIST.length, }, updated_by: "system", updated_at: new Date().toISOString(), }); logger.info({ unitsFound: units.length, firstCity: units[0]?.city, firstIndustry: units[0]?.industry, attempts, }, "Territory units selected for today"); return units; } /** * Check if a city+industry combo is fresh (not searched in 30 days). */ async function isTerritoryFresh(territoryId: string, industry: string): Promise { const db = getSupabaseClient(); const { data } = await db .from("territory_progress") .select("next_eligible_at") .eq("territory_id", territoryId) .eq("industry", industry) .maybeSingle(); if (!data) return true; // never searched → fresh const eligible = new Date(data.next_eligible_at); return new Date() >= eligible; } /** * Mark a territory unit as searched (sets 30-day cooldown). */ export async function markTerritorySearched( territoryId: string, industry: string, leadsFound: number ): Promise { const db = getSupabaseClient(); const now = new Date(); const nextEligible = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // +30 days await db.from("territory_progress").upsert({ territory_id: territoryId, industry, last_run_at: now.toISOString(), next_eligible_at: nextEligible.toISOString(), total_leads: leadsFound, }, { onConflict: "territory_id,industry" }); } /** * Get today's lead quota (default or override). */ export async function getDailyQuota(): Promise { const db = getSupabaseClient(); const { data } = await db .from("system_config") .select("value") .eq("key", "daily_quota") .single(); const config = data?.value as { default: number; today_override: number | null } | null; if (config?.today_override !== null && config?.today_override !== undefined) { // Clear override after reading (one-time use) await db.from("system_config").update({ value: { ...config, today_override: null }, updated_at: new Date().toISOString(), }).eq("key", "daily_quota"); return config.today_override; } return config?.default ?? 10; } /** * Set today's quota override (from Slack command). */ export async function setQuotaOverride(quota: number, permanent = false): Promise { const db = getSupabaseClient(); if (permanent) { await db.from("system_config").update({ value: { default: quota, today_override: null }, updated_by: "slack", updated_at: new Date().toISOString(), }).eq("key", "daily_quota"); } else { const { data } = await db .from("system_config") .select("value") .eq("key", "daily_quota") .single(); const current = data?.value as { default: number } | null; await db.from("system_config").update({ value: { default: current?.default ?? 10, today_override: quota }, updated_by: "slack", updated_at: new Date().toISOString(), }).eq("key", "daily_quota"); } } /** * Check if system is paused. */ export async function isSystemPaused(): Promise { const db = getSupabaseClient(); const { data } = await db .from("system_config") .select("value") .eq("key", "auto_mode") .single(); return (data?.value as { paused?: boolean })?.paused === true; } /** * Build Google search queries for a territory unit. * Generates 3-4 targeted queries per city+industry. */ export function buildTerritoryQueries(unit: TerritoryUnit, keywords: string[]): string[] { return [ `"${unit.industry}" company "${unit.city}" "${unit.country}" -job -careers`, `best ${unit.industry} companies in ${unit.city} ${unit.country}`, `"${unit.industry}" business "${unit.city}" "${keywords[0] ?? ""}" site:linkedin.com/company`, `top ${unit.industry} ${unit.city} companies ${new Date().getFullYear()}`, ].filter(q => q.trim().length > 10); }