Spaces:
Running
Running
| /** | |
| * 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<TerritoryUnit[]> { | |
| 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<boolean> { | |
| 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<void> { | |
| 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<number> { | |
| 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<void> { | |
| 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<boolean> { | |
| 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); | |
| } | |