clienttarget-python / src /discovery /lib /territory-manager.ts
iDevBuddy
feat: Phase 1 — AI Client Acquisition System
bd28470
/**
* 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);
}