@foundrynorth/compass-schema 1.0.10 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1072 @@
1
+ // shared/analyzeTypes.ts
2
+
3
+ import { z } from 'zod';
4
+
5
+ /**
6
+ * SERVICE AREA TYPES
7
+ * For home service businesses (HVAC, plumbing, electrical, etc.),
8
+ * competition is based on service area overlap rather than physical distance.
9
+ */
10
+
11
+ export type BusinessType = 'retail' | 'restaurant' | 'service_business' | 'other';
12
+
13
+ export interface ServiceArea {
14
+ zips?: string[];
15
+ cities?: string[];
16
+ namedRegions?: string[];
17
+ radiusMiles?: number;
18
+ coverageScore?: number;
19
+ }
20
+
21
+ export interface ServiceAreaOverlap {
22
+ overlapZips: string[];
23
+ overlapCities: string[];
24
+ overlapRatio: number;
25
+ competitorTier: 'primary' | 'secondary' | 'peripheral';
26
+ }
27
+
28
+ /**
29
+ * CANONICAL COMPETITOR TYPE
30
+ * Single source of truth for competitor representation across the platform.
31
+ * Use this type for:
32
+ * - Enrichment persistence
33
+ * - Prompt construction
34
+ * - UI display on competitor cards
35
+ *
36
+ * This type normalizes competitors from all sources (Google Places, DataForSEO, Perplexity)
37
+ * into a consistent shape for the entire application.
38
+ */
39
+ export interface CanonicalCompetitor {
40
+ id: string;
41
+ name: string;
42
+ domain?: string;
43
+ placeId?: string;
44
+ location?: {
45
+ lat: number;
46
+ lng: number;
47
+ city?: string;
48
+ state?: string;
49
+ address?: string;
50
+ };
51
+ category?: string;
52
+ sources: string[];
53
+ metrics?: {
54
+ searchShare?: number;
55
+ estClicks?: number;
56
+ estSpend?: number;
57
+ rating?: number;
58
+ reviewCount?: number;
59
+ };
60
+
61
+ // Chain/multi-location support
62
+ isChain?: boolean;
63
+ chainId?: string;
64
+ chainName?: string;
65
+ chainType?: 'national' | 'regional' | 'local';
66
+ locations?: ChainLocation[];
67
+ locationCount?: number;
68
+ /**
69
+ * Selection mode for multi-location chains:
70
+ * - 'single': Only this specific location
71
+ * - 'dma': All locations in the same DMA as prospect
72
+ * - 'overlap': All locations overlapping with prospect's service area
73
+ * - 'all': All locations nationwide (legacy 'chain' mode)
74
+ */
75
+ selectionMode?: ChainSelectionMode;
76
+ // Which locations were selected (place_ids)
77
+ selectedLocationIds?: string[];
78
+ // Counts by selection mode (populated by worker when DMA data available)
79
+ locationsByMode?: {
80
+ dma?: number; // Count of locations in prospect's DMA
81
+ overlap?: number; // Count of locations overlapping prospect service area
82
+ all: number; // Total location count
83
+ };
84
+ aggregatedMetrics?: {
85
+ totalReviews?: number;
86
+ avgRating?: number;
87
+ serviceAreaUnion?: ServiceArea;
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Chain selection mode for multi-location businesses
93
+ * Determines how many locations to include in analysis
94
+ */
95
+ export type ChainSelectionMode = 'single' | 'dma' | 'overlap' | 'all';
96
+
97
+ /**
98
+ * Individual location within a chain
99
+ * Used when a competitor or prospect has multiple locations in the market
100
+ */
101
+ export interface ChainLocation {
102
+ placeId: string;
103
+ name: string;
104
+ address?: string;
105
+ city?: string;
106
+ state?: string;
107
+ lat?: number;
108
+ lng?: number;
109
+ distance_km?: number;
110
+ rating?: number;
111
+ reviewCount?: number;
112
+ isPrimary?: boolean;
113
+ // DMA/geographic filtering (populated by worker when available)
114
+ dmaCode?: string; // DMA code (e.g., "613" for Minneapolis)
115
+ dmaName?: string; // DMA name (e.g., "Minneapolis-St. Paul")
116
+ inProspectDma?: boolean; // Is this location in the same DMA as prospect?
117
+ overlapsProspect?: boolean; // Does this location's service area overlap prospect?
118
+ }
119
+
120
+ /**
121
+ * COMPETITOR CREATIVE TYPES
122
+ * Unified interface for ad creatives from Google Ads and Facebook Ads
123
+ * Used for UI display and media planning prompts
124
+ */
125
+ export interface CompetitorCreative {
126
+ source: 'google_ads' | 'facebook_ads';
127
+ platform: 'google' | 'facebook' | 'instagram' | 'audience_network';
128
+ accountName?: string;
129
+ accountId?: string;
130
+ headline?: string;
131
+ primaryText?: string;
132
+ description?: string;
133
+ cta?: string;
134
+ ctaType?: string;
135
+ landingUrl?: string;
136
+ imageUrl?: string;
137
+ imageUrls?: string[] | Array<{ original: string; resized: string }>;
138
+ videoUrl?: string;
139
+ format?: string; // responsive search, display, video, carousel, etc
140
+ impressions?: number | null;
141
+ region?: string | null;
142
+ startDate?: string | null;
143
+ endDate?: string | null;
144
+ pageProfileUrl?: string;
145
+ pageProfilePicture?: string;
146
+ pageLikeCount?: number;
147
+ pageCategories?: string[];
148
+ adLibraryUrl?: string;
149
+ sourceUrl?: string;
150
+ raw?: unknown; // keep raw payload for debugging
151
+ }
152
+
153
+ /**
154
+ * Facebook Ad structure (from curious_coder/facebook-ads-library-scraper)
155
+ * Extended to include all fields from the actual API response
156
+ */
157
+ export interface FacebookAdCreative {
158
+ adId?: string;
159
+ adArchiveId?: string;
160
+ adImage?: string;
161
+ adImages?: Array<{ original: string; resized: string }>;
162
+ adCopy?: string;
163
+ headline?: string;
164
+ callToAction?: string;
165
+ ctaType?: string;
166
+ ctaLink?: string;
167
+ publisherPlatforms?: string[];
168
+ startDate?: string;
169
+ endDate?: string;
170
+ status?: string;
171
+ pageName?: string;
172
+ pageProfileUrl?: string;
173
+ pageProfilePicture?: string;
174
+ pageLikeCount?: number;
175
+ pageCategories?: string[];
176
+ displayFormat?: string;
177
+ adLibraryUrl?: string;
178
+ }
179
+
180
+ /**
181
+ * Summary of creatives for a competitor
182
+ */
183
+ export interface CompetitorCreativeSummary {
184
+ googleAdsCount: number;
185
+ facebookAdsCount: number;
186
+ totalCount: number;
187
+ creatives: CompetitorCreative[];
188
+ }
189
+
190
+ /**
191
+ * CRITICAL CHANGE: Known competitors are now REQUIRED (1-5)
192
+ * Discovery is OPTIONAL and defaults to OFF
193
+ */
194
+ export const KnownCompetitorSchema = z.object({
195
+ name: z.string()
196
+ .min(2, 'Competitor name must be at least 2 characters')
197
+ .max(100, 'Competitor name too long'),
198
+
199
+ placeId: z.string().optional(),
200
+ // If user already has the Google Place ID (e.g., from previous search)
201
+
202
+ notes: z.string().max(200).optional(),
203
+ // Optional context: "Main competitor", "They dominate paid search", etc.
204
+
205
+ address: z.string().optional(),
206
+ // User can provide address to help disambiguation
207
+
208
+ website: z.string()
209
+ .optional()
210
+ .transform((val) => {
211
+ // Allow flexible URL input - normalize to full URL
212
+ if (!val || val.trim() === '') return undefined;
213
+ const trimmed = val.trim();
214
+
215
+ // Already has protocol
216
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
217
+ return trimmed;
218
+ }
219
+
220
+ // Add https:// if missing
221
+ return `https://${trimmed}`;
222
+ })
223
+ // If user knows the website, helps with validation
224
+ });
225
+
226
+ export const AnalyzePlanInputSchema = z.object({
227
+ // =================================================
228
+ // PROSPECT INFORMATION (mostly unchanged)
229
+ // =================================================
230
+ name: z.string().optional(),
231
+ placeId: z.string().optional(),
232
+
233
+ // Legacy/alternative field names for compatibility
234
+ brand: z.string().optional(), // Alternative to 'name'
235
+ businessName: z.string().optional(), // Alternative to 'name'
236
+
237
+ city: z.string().optional(),
238
+ state: z.string().optional(),
239
+ geo: z.string().optional(),
240
+ targetGeo: z.string().optional(), // Alternative to 'geo'
241
+ lat: z.number().optional(),
242
+ lng: z.number().optional(),
243
+
244
+ // Chain detection and area selection
245
+ chainDetected: z.boolean().optional(),
246
+ selectedAreas: z.array(z.string()).optional(), // Selected geographic areas for chain analysis
247
+
248
+ businessDescription: z.string()
249
+ .optional()
250
+ .default('')
251
+ .transform((val) => val?.trim() || ''),
252
+ // businessDescription is OPTIONAL - category and placeId provide sufficient context for analysis
253
+
254
+ url: z.string()
255
+ .optional()
256
+ .transform((val) => {
257
+ // Allow flexible URL input - normalize to full URL
258
+ if (!val || val.trim() === '') return undefined;
259
+ const trimmed = val.trim();
260
+
261
+ // Already has protocol
262
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
263
+ return trimmed;
264
+ }
265
+
266
+ // Add https:// if missing
267
+ return `https://${trimmed}`;
268
+ }),
269
+
270
+ // =================================================
271
+ // COMPETITOR INFORMATION (NEW PRIMARY INPUT)
272
+ // =================================================
273
+ knownCompetitors: z.array(KnownCompetitorSchema)
274
+ .min(1, 'Provide at least 1 known competitor')
275
+ .max(5, 'Maximum 5 competitors allowed')
276
+ .refine(
277
+ (competitors) => {
278
+ const names = competitors.map(c => c.name.toLowerCase().trim());
279
+ return new Set(names).size === names.length;
280
+ },
281
+ 'Duplicate competitor names not allowed'
282
+ ),
283
+
284
+ // =================================================
285
+ // OPTIONAL AI DISCOVERY
286
+ // =================================================
287
+ discoverAdditional: z.boolean().default(false),
288
+ // Should we find 1-2 more competitors?
289
+
290
+ maxAdditionalCompetitors: z.number()
291
+ .min(0)
292
+ .max(3)
293
+ .default(1),
294
+ // How many additional to find (if discovery enabled)
295
+
296
+ // =================================================
297
+ // ACTIVE MARKET SELECTION (NEW)
298
+ // =================================================
299
+ marketId: z.string().optional(),
300
+ // The active market ID for location bias (e.g., 'mkt_twin-cities')
301
+
302
+ marketCenter: z.object({
303
+ lat: z.number(),
304
+ lng: z.number()
305
+ }).optional(),
306
+ // Market center coordinates for location-biased API calls
307
+
308
+ // =================================================
309
+ // SERVICE AREA CONFIGURATION (NEW)
310
+ // =================================================
311
+ serviceAreaConfig: z.object({
312
+ type: z.enum(['physical', 'hq_radius', 'metro', 'custom_zips']).default('physical'),
313
+ radiusMiles: z.number().min(5).max(150).optional(),
314
+ zipCodes: z.array(z.string()).optional(),
315
+ }).optional(),
316
+ // Configuration details: physical (single location), hq_radius (X miles from HQ),
317
+ // metro (entire metro area), custom_zips (specific zip codes)
318
+
319
+ // =================================================
320
+ // PROSPECT CHAIN/MULTI-LOCATION CONFIGURATION
321
+ // =================================================
322
+ prospectPlaceId: z.string().optional(),
323
+ // Google Place ID for the prospect (if chain, this is the selected location)
324
+
325
+ prospectBrandName: z.string().optional(),
326
+ // Canonical brand name for chains (e.g., "Caribou Coffee" instead of "Caribou Coffee - Minneapolis")
327
+ // Used by worker for location enumeration. For non-chains, this may equal the display name.
328
+
329
+ prospectChainMode: z.enum(['single', 'dma', 'overlap', 'all']).optional(),
330
+ // How to handle prospect if they are a multi-location chain:
331
+ // - 'single': Analyze only the selected location
332
+ // - 'dma': Analyze all locations in the prospect's DMA
333
+ // - 'overlap': Analyze locations overlapping with active market
334
+ // - 'all': Analyze all locations (default legacy behavior)
335
+
336
+ prospectLocationCount: z.number().optional(),
337
+ // Total locations for the prospect chain (for UI display)
338
+
339
+ // Option D: user-selected locations from multi-result search (worker uses these instead of enumerating)
340
+ prospectLocations: z.array(z.object({
341
+ placeId: z.string(),
342
+ name: z.string().optional(),
343
+ address: z.string().optional(),
344
+ city: z.string().optional(),
345
+ state: z.string().optional(),
346
+ lat: z.number().optional(),
347
+ lng: z.number().optional(),
348
+ })).optional(),
349
+
350
+ // =================================================
351
+ // OPTIONAL METADATA
352
+ // =================================================
353
+ budget: z.object({
354
+ min: z.number().positive(),
355
+ max: z.number().positive()
356
+ }).optional(),
357
+
358
+ planTotalBudget: z.number().positive().optional(),
359
+ planDurationMonths: z.number().int().positive().optional(),
360
+
361
+ goals: z.array(z.string()).optional(),
362
+
363
+ // User override for business category (takes precedence over Google Places)
364
+ category: z.string().optional(),
365
+
366
+ // Analysis depth - controls how deep the enrichment and planning phases go
367
+ analysisDepth: z.enum(['standard', 'deep']).optional().default('deep'),
368
+
369
+ // =================================================
370
+ // HUBSPOT PRE-LINKED COMPANY (from frontend typeahead)
371
+ // =================================================
372
+ hubspotCompanyId: z.string().optional(),
373
+ hubspotUrl: z.string().optional(),
374
+ });
375
+
376
+ export type KnownCompetitor = z.infer<typeof KnownCompetitorSchema>;
377
+ export type AnalyzePlanInput = z.infer<typeof AnalyzePlanInputSchema>;
378
+
379
+ /**
380
+ * Validation state after processing
381
+ */
382
+ export interface ValidatedCompetitor extends KnownCompetitor {
383
+ // Google Places validation results
384
+ validated: boolean | 'needs_clarification';
385
+ status: 'confirmed' | 'not_found' | 'multiple_matches';
386
+ confidence: number; // 0-100
387
+
388
+ // If validated successfully
389
+ placeId?: string;
390
+ verifiedName?: string; // Official name from Google
391
+ verifiedAddress?: string;
392
+ geometry?: {
393
+ lat: number;
394
+ lng: number;
395
+ };
396
+ category?: string;
397
+ rating?: number;
398
+ reviewCount?: number;
399
+
400
+ // If multiple matches found
401
+ options?: Array<{
402
+ placeId: string;
403
+ name: string;
404
+ address: string;
405
+ distance?: number; // km from prospect
406
+ }>;
407
+
408
+ // If not found
409
+ suggestion?: string;
410
+
411
+ // Source tracking
412
+ source: 'user_provided' | 'ai_suggested';
413
+ originalName?: string; // What user typed vs. what Google says
414
+
415
+ // Service area support (for HVAC, plumbing, etc.)
416
+ businessType?: BusinessType;
417
+ serviceArea?: ServiceArea | null;
418
+ serviceAreaOverlap?: ServiceAreaOverlap | null;
419
+ }
420
+
421
+ /**
422
+ * Base business profile (minimal required fields)
423
+ */
424
+ export interface BaseBusinessProfile {
425
+ name: string;
426
+ placeId: string | null;
427
+ address: string;
428
+ category?: string;
429
+ rating?: number;
430
+ reviewCount?: number;
431
+ phone?: string;
432
+ website?: string;
433
+ seeded?: boolean;
434
+ source?: string;
435
+ validationBypassed?: boolean;
436
+ bypassedAI?: boolean;
437
+ lat?: number;
438
+ lng?: number;
439
+ }
440
+
441
+ /**
442
+ * Enrichment augmentation data (additional fields added during enrichment)
443
+ */
444
+ export interface EnrichmentAugmentation {
445
+ digitalMaturity?: number;
446
+ maturityStage?: string;
447
+ strengths?: string[];
448
+ gaps?: string[];
449
+ competitiveContext?: any;
450
+ comparativeInsights?: any;
451
+ [key: string]: any; // Allow additional enrichment fields
452
+ }
453
+
454
+ /**
455
+ * Validated business (base profile + enrichment data)
456
+ */
457
+ export interface ValidatedBusiness extends BaseBusinessProfile, Partial<EnrichmentAugmentation> {
458
+ validated: boolean;
459
+ }
460
+
461
+ /**
462
+ * Fully enriched business (all enrichment fields guaranteed)
463
+ */
464
+ export interface FullyEnrichedBusiness extends ValidatedBusiness {
465
+ digitalMaturity: number;
466
+ maturityStage: string;
467
+ strengths: string[];
468
+ gaps: string[];
469
+ }
470
+
471
+ /**
472
+ * Enrichment result structure
473
+ */
474
+ export interface EnrichedBusiness {
475
+ // Core identity
476
+ placeId: string;
477
+ name: string;
478
+ address: string;
479
+ city: string;
480
+ state: string;
481
+ lat: number;
482
+ lng: number;
483
+ category: string;
484
+
485
+ // Service area support (for HVAC, plumbing, etc.)
486
+ businessType?: BusinessType;
487
+ serviceArea?: ServiceArea | null;
488
+ serviceAreaOverlap?: ServiceAreaOverlap | null;
489
+
490
+ // Basic metrics
491
+ rating: number;
492
+ reviewCount: number;
493
+
494
+ // DEEP enrichment data
495
+ metaAds: {
496
+ present: boolean;
497
+ activeCount: number;
498
+ totalSeen: number;
499
+ oldestAdDate: string | null;
500
+ impressionsRange: string | null;
501
+
502
+ // NEW: Creative analysis
503
+ creative?: {
504
+ themes: string[]; // ["discount", "quality", "emergency service"]
505
+ messaging: string[]; // Key phrases from ad copy
506
+ visualStyle: string; // "professional" | "playful" | "urgent"
507
+ offers: string[]; // ["20% off", "free estimate", "same-day"]
508
+ ctaTypes: string[]; // ["Learn More", "Book Now", "Call Now"]
509
+ // Actual ad data from Facebook Ads Library (via Apify)
510
+ ads?: Array<{
511
+ adId?: string;
512
+ adImage?: string; // URL to ad creative image
513
+ adCopy?: string; // Full ad body text
514
+ headline?: string; // Ad headline
515
+ callToAction?: string; // CTA button text
516
+ publisherPlatforms?: string[]; // ["Facebook", "Instagram"]
517
+ startDate?: string; // When ad started running
518
+ status?: string; // "active" | "inactive"
519
+ }>;
520
+ };
521
+
522
+ // NEW: Audience insights
523
+ audience?: {
524
+ platforms: string[]; // ["Facebook", "Instagram"]
525
+ ageRanges: string[]; // ["25-34", "35-44"]
526
+ genders: string[]; // ["All", "Male", "Female"]
527
+ interests: string[]; // Inferred from creative themes
528
+ };
529
+
530
+ // NEW: Spend estimation
531
+ estimatedSpend?: {
532
+ monthly: number;
533
+ confidence: 'low' | 'medium' | 'high';
534
+ basis: string; // How we calculated it
535
+ };
536
+ };
537
+
538
+ // Website analysis
539
+ website: {
540
+ url: string | null;
541
+ accessible: boolean;
542
+
543
+ // NEW: SEO audit
544
+ seo?: {
545
+ titleTag: string;
546
+ metaDescription: string;
547
+ h1Tags: string[];
548
+ contentWordCount: number;
549
+ keywordDensity: { [keyword: string]: number };
550
+
551
+ // Technical SEO
552
+ mobileSpeed: number; // Google PageSpeed score 0-100
553
+ desktopSpeed: number;
554
+ coreWebVitals: boolean;
555
+ httpsEnabled: boolean;
556
+ structuredData: string[]; // ["LocalBusiness", "Organization"]
557
+
558
+ // Local SEO
559
+ nabConsistency: boolean; // Name/Address/Phone match
560
+ localKeywords: string[];
561
+ locationPages: number;
562
+ };
563
+
564
+ // NEW: Conversion optimization
565
+ conversion?: {
566
+ hasBooking: boolean;
567
+ hasContactForm: boolean;
568
+ hasPhoneNumber: boolean;
569
+ hasChatWidget: boolean;
570
+ ctaCount: number;
571
+ ctaTypes: string[];
572
+ };
573
+ };
574
+
575
+ // Existing fields
576
+ technologies: string[];
577
+ sophistication: 'basic' | 'intermediate' | 'advanced';
578
+ hasEcommerce: boolean;
579
+ hasAnalytics: boolean;
580
+ hasBlog: boolean;
581
+ mobileOptimized: boolean;
582
+ loadTime: number;
583
+
584
+ // Social media
585
+ social: {
586
+ facebook?: {
587
+ url: string;
588
+ followers: number;
589
+ postsPerWeek: number;
590
+ engagementRate: number;
591
+ lastPostDate: string;
592
+ };
593
+ instagram?: {
594
+ url: string;
595
+ followers: number;
596
+ postsPerWeek: number;
597
+ engagementRate: number;
598
+ lastPostDate: string;
599
+ };
600
+ linkedin?: {
601
+ url: string;
602
+ followers: number;
603
+ };
604
+ };
605
+
606
+ // Google Ads from Transparency Center (Creative Vault)
607
+ googleAds?: {
608
+ active: boolean;
609
+ adCount: number;
610
+ advertiserName?: string;
611
+ lastScrapedAt?: Date;
612
+ fromCache?: boolean;
613
+ ads?: Array<{
614
+ id: string;
615
+ adType: 'image' | 'video' | 'text';
616
+ imageUrl?: string;
617
+ videoUrl?: string;
618
+ headline?: string;
619
+ description?: string;
620
+ landingPage?: string;
621
+ advertiser?: string;
622
+ format?: string;
623
+ lastShownDate?: string;
624
+ regions?: string[];
625
+ }>;
626
+ };
627
+
628
+ // Unified creatives array (normalized from Google Ads + Facebook Ads)
629
+ // This is the primary field for UI display and media planning prompts
630
+ creatives?: CompetitorCreative[];
631
+
632
+ // Strategic analysis (from GPT-4o)
633
+ strategy: {
634
+ channelPresence: {
635
+ [channel: string]: {
636
+ active: boolean;
637
+ evidence: string;
638
+ investmentLevel: 'low' | 'medium' | 'high';
639
+ };
640
+ };
641
+
642
+ messaging: {
643
+ primaryThemes: string[];
644
+ positioning: string;
645
+ tone: 'professional' | 'casual' | 'playful' | 'authoritative';
646
+ };
647
+
648
+ sophistication: {
649
+ level: 'beginner' | 'intermediate' | 'advanced';
650
+ signals: string[];
651
+ investmentLevel: 'low' | 'medium' | 'high';
652
+ };
653
+
654
+ strengths: string[];
655
+ gaps: string[];
656
+ };
657
+
658
+ // Digital maturity score
659
+ digitalMaturity: number; // 0-100
660
+ maturityStage: 'Foundation' | 'Growth' | 'Advanced';
661
+
662
+ // Source tracking
663
+ source: 'prospect' | 'user_provided' | 'ai_suggested';
664
+ validated: boolean;
665
+ }
666
+
667
+ // ============================================================================
668
+ // PLANNING CONTEXT DTO
669
+ // Single structured object for media plan LLM prompts
670
+ // Consolidates prospect intelligence, competitor data, and benchmarks
671
+ // ============================================================================
672
+
673
+ /**
674
+ * Prospect context for media planning
675
+ * Contains essential business information extracted from enrichment
676
+ */
677
+ export interface PlanningProspect {
678
+ name: string;
679
+ category: string;
680
+ serviceAreaDescription: string;
681
+ websiteSummary?: string;
682
+ keyValueProps?: string[];
683
+ currentChannels?: string[];
684
+ weaknesses?: string[];
685
+ gaps?: string[];
686
+ }
687
+
688
+ /**
689
+ * Competitor context for media planning
690
+ * Summarized competitive intelligence including creative themes
691
+ */
692
+ export interface PlanningCompetitor {
693
+ name: string;
694
+ category: string;
695
+ serviceAreaDescription?: string;
696
+ adSpendLevel?: 'low' | 'medium' | 'high';
697
+ googleAdsThemes?: string[];
698
+ metaAdsThemes?: string[];
699
+ landingPagePatterns?: string[];
700
+ activeChannels?: string[];
701
+ investmentLevel?: 'low' | 'medium' | 'high';
702
+ }
703
+
704
+ /**
705
+ * Performance benchmarks for a product/channel
706
+ * Used to inform realistic KPI expectations
707
+ */
708
+ export interface PlanningBenchmark {
709
+ productCode: string;
710
+ mappedIndustry: string;
711
+ cpm?: number;
712
+ ctr?: number;
713
+ cvr?: number;
714
+ viewability?: number;
715
+ }
716
+
717
+ /**
718
+ * PlanningContext DTO
719
+ * Single structured object passed to media plan LLM
720
+ *
721
+ * This replaces scattered string concatenation with a clean JSON structure
722
+ * that the model can reference systematically when generating plans.
723
+ */
724
+ export interface PlanningContext {
725
+ prospect: PlanningProspect;
726
+ competitors: PlanningCompetitor[];
727
+ performanceBenchmarks: PlanningBenchmark[];
728
+
729
+ // Market analysis
730
+ competitorCount: number;
731
+ advertisingIntensity: 'low' | 'medium' | 'high';
732
+ marketGaps: string[];
733
+
734
+ // Creative strategy summary (from Google/Meta ads analysis)
735
+ creativeStrategySummary?: string;
736
+ }
737
+
738
+ // ============================================================================
739
+ // LOCAL MARKET SCORE TYPES
740
+ // Simplified 0-100 score for sales conversations
741
+ // ============================================================================
742
+
743
+ /**
744
+ * Local Market Score - single 0-100 number for quick comparison
745
+ * Distills the 5-pillar Local Authority Score into a sales-friendly format
746
+ */
747
+ export interface LocalMarketScore {
748
+ /** Single 0-100 score for quick comparison */
749
+ score: number;
750
+ /** Classification for sales conversations */
751
+ tier: 'Leader' | 'Competitive' | 'Emerging' | 'At Risk';
752
+ /** Color code for UI (red, orange, yellow, gray) */
753
+ tierColor: 'red' | 'orange' | 'yellow' | 'gray';
754
+ /** One-line summary for sales pitch */
755
+ summary: string;
756
+ /** Component breakdown for details */
757
+ breakdown: {
758
+ localPack: { score: number; maxScore: 40; label: string };
759
+ reviews: { score: number; maxScore: 30; label: string };
760
+ gbpProfile: { score: number; maxScore: 20; label: string };
761
+ digital: { score: number; maxScore: 10; label: string };
762
+ };
763
+ /** Calculated timestamp */
764
+ calculatedAt: string;
765
+ }
766
+
767
+ // ============================================================================
768
+ // REVENUE IMPACT TYPES
769
+ // Connect local search performance to dollars
770
+ // ============================================================================
771
+
772
+ /**
773
+ * Revenue Impact - quantifies the dollar value of local search position
774
+ */
775
+ export interface RevenueImpact {
776
+ /** Monthly revenue prospect is currently capturing */
777
+ currentMonthlyRevenue: number;
778
+ /** Monthly revenue the market leader captures */
779
+ leaderMonthlyRevenue: number;
780
+ /** Monthly revenue gap (what prospect is missing) */
781
+ monthlyRevenueGap: number;
782
+ /** Annual revenue opportunity */
783
+ annualOpportunity: number;
784
+ /** Benchmarks used for calculation */
785
+ benchmarks: {
786
+ avgTicket: number;
787
+ conversionRate: number;
788
+ category: string;
789
+ };
790
+ /** Position-based CTR breakdown */
791
+ positionAnalysis: {
792
+ prospectCTR: number;
793
+ leaderCTR: number;
794
+ ctrGap: number;
795
+ };
796
+ /** Funnel metrics for transparency */
797
+ funnel: {
798
+ monthlySearchVolume: number;
799
+ prospectClicks: number;
800
+ prospectConversions: number;
801
+ leaderClicks: number;
802
+ leaderConversions: number;
803
+ };
804
+ /** Sales-ready talking points */
805
+ insights: {
806
+ headline: string;
807
+ comparison: string;
808
+ opportunity: string;
809
+ };
810
+ /** Calculation timestamp */
811
+ calculatedAt: string;
812
+ }
813
+
814
+ // ============================================================================
815
+ // COMPETITIVE NARRATIVE TYPES
816
+ // Wins vs Losses storytelling for sales conversations
817
+ // ============================================================================
818
+
819
+ /**
820
+ * Single comparison point in a head-to-head analysis
821
+ */
822
+ export interface WinLossItem {
823
+ /** What's being compared */
824
+ metric: string;
825
+ /** Prospect's value/status */
826
+ prospectValue: string | number;
827
+ /** Competitor's value/status */
828
+ competitorValue: string | number;
829
+ /** Whether prospect wins this comparison */
830
+ prospectWins: boolean;
831
+ /** Impact level for prioritization */
832
+ impact: 'high' | 'medium' | 'low';
833
+ /** One-sentence insight for sales pitch */
834
+ insight: string;
835
+ /** Actionable recommendation */
836
+ action?: string;
837
+ }
838
+
839
+ /**
840
+ * Head-to-head comparison with a single competitor
841
+ */
842
+ export interface HeadToHeadComparison {
843
+ /** Competitor being compared */
844
+ competitorName: string;
845
+ /** List of wins for prospect */
846
+ wins: WinLossItem[];
847
+ /** List of losses for prospect */
848
+ losses: WinLossItem[];
849
+ /** Overall assessment */
850
+ summary: {
851
+ winCount: number;
852
+ lossCount: number;
853
+ verdict: 'prospect_leads' | 'competitor_leads' | 'close_match';
854
+ headline: string;
855
+ };
856
+ /** Priority actions based on losses */
857
+ priorityActions: string[];
858
+ /** Calculated at timestamp */
859
+ calculatedAt: string;
860
+ }
861
+
862
+ /**
863
+ * Full competitive narrative across all competitors
864
+ */
865
+ export interface CompetitiveNarrative {
866
+ /** Prospect name */
867
+ prospectName: string;
868
+ /** Head-to-head comparisons with each competitor */
869
+ headToHead: HeadToHeadComparison[];
870
+ /** Market-level summary */
871
+ marketSummary: {
872
+ /** Where prospect wins vs the market */
873
+ marketWins: string[];
874
+ /** Where prospect loses vs the market */
875
+ marketLosses: string[];
876
+ /** Overall market position */
877
+ position: 'leader' | 'competitive' | 'challenger' | 'at_risk';
878
+ /** Executive summary paragraph */
879
+ executiveSummary: string;
880
+ };
881
+ /** Top 3 actions to take */
882
+ topActions: Array<{
883
+ action: string;
884
+ impact: string;
885
+ competitor: string;
886
+ }>;
887
+ /** Calculated at timestamp */
888
+ calculatedAt: string;
889
+ }
890
+
891
+ // ============================================================================
892
+ // INTELLIGENCE DATA — Aggregated enrichment outputs from fn-v2 pipeline
893
+ // Written to plans.intelligenceData JSONB column
894
+ // ============================================================================
895
+
896
+ /** Prospect enrichment from business lookup + website analysis */
897
+ export interface IntelligenceProspect extends PlanningProspect {
898
+ url?: string;
899
+ placeId?: string;
900
+ address?: string;
901
+ city?: string;
902
+ state?: string;
903
+ phone?: string;
904
+ rating?: number;
905
+ reviewCount?: number;
906
+ techStack?: string[];
907
+ digitalMaturityScore?: number;
908
+ pixelsDetected?: string[];
909
+ socialProfiles?: Record<string, string>;
910
+ /** Nielsen DMA code for market intelligence lookups */
911
+ dmaCode?: string;
912
+ }
913
+
914
+ /** Ad intelligence for a single competitor or prospect */
915
+ export interface AdIntelligenceEntry {
916
+ entityName: string;
917
+ googleAdsCount?: number;
918
+ googleAdsThemes?: string[];
919
+ metaAdsCount?: number;
920
+ metaAdsThemes?: string[];
921
+ estimatedMonthlySpend?: number;
922
+ activeChannels?: string[];
923
+ creativeExamples?: Array<{ platform: string; headline?: string; description?: string; imageUrl?: string }>;
924
+ }
925
+
926
+ /** Pixel / tracking detection results */
927
+ export interface PixelDetectionResult {
928
+ url: string;
929
+ pixelsFound: string[];
930
+ analyticsTools: string[];
931
+ adPlatforms: string[];
932
+ tagManagers: string[];
933
+ remarketingActive: boolean;
934
+ }
935
+
936
+ /** Domain SEO overview */
937
+ export interface DomainOverviewResult {
938
+ domain: string;
939
+ organicKeywords?: number;
940
+ organicTraffic?: number;
941
+ domainAuthority?: number;
942
+ backlinks?: number;
943
+ topKeywords?: Array<{ keyword: string; position: number; volume: number }>;
944
+ }
945
+
946
+ /** SEO keyword/ranking analysis (distinct from domain-level overview) */
947
+ export interface SeoAnalysisResult {
948
+ domain: string;
949
+ totalKeywordsTracked?: number;
950
+ keywordsInTop10?: number;
951
+ keywordsInTop3?: number;
952
+ organicTraffic?: number;
953
+ topKeywords?: Array<{ keyword: string; position: number; volume: number; difficulty?: number }>;
954
+ contentGaps?: string[];
955
+ technicalIssues?: string[];
956
+ }
957
+
958
+ /** Landing page audit results */
959
+ export interface LandingPageAuditResult {
960
+ url: string;
961
+ mobileScore?: number;
962
+ desktopScore?: number;
963
+ loadTimeMs?: number;
964
+ hasCtaAboveFold?: boolean;
965
+ hasForm?: boolean;
966
+ hasPhoneNumber?: boolean;
967
+ issues?: string[];
968
+ }
969
+
970
+ /** Social media presence data */
971
+ export interface SocialDataResult {
972
+ profiles: Array<{
973
+ platform: string;
974
+ url?: string;
975
+ followers?: number;
976
+ postsPerWeek?: number;
977
+ engagementRate?: number;
978
+ }>;
979
+ }
980
+
981
+ /** Strategy brief output */
982
+ export interface StrategyBriefResult {
983
+ executiveSummary: string;
984
+ marketPosition: string;
985
+ recommendedChannels: Array<{
986
+ channel: string;
987
+ productCode?: string;
988
+ rationale: string;
989
+ priority: 'high' | 'medium' | 'low';
990
+ }>;
991
+ competitiveGaps: string[];
992
+ targetAudience?: string;
993
+ messagingThemes?: string[];
994
+ }
995
+
996
+ /** Market context from market intelligence tables */
997
+ export interface MarketContextResult {
998
+ seasonality?: {
999
+ type: string;
1000
+ score: number;
1001
+ peakMonths: string[];
1002
+ monthlyMultipliers: Record<string, number>;
1003
+ };
1004
+ benchmarks?: {
1005
+ avgTicket: number;
1006
+ conversionRate: number;
1007
+ marketSaturationScore?: number;
1008
+ yoyGrowthRate?: number;
1009
+ };
1010
+ marketScore?: {
1011
+ overallScore: number;
1012
+ timingScore: number;
1013
+ competitionScore: number;
1014
+ economicScore?: number;
1015
+ recommendation: string;
1016
+ rationale?: string;
1017
+ };
1018
+ competitiveLandscape?: {
1019
+ totalBusinesses?: number;
1020
+ advertisingPenetration?: number;
1021
+ marketConcentration?: string;
1022
+ opportunityScore?: number;
1023
+ };
1024
+ economicIndicators?: {
1025
+ consumerConfidenceIndex?: number;
1026
+ unemploymentRate?: number;
1027
+ medianHouseholdIncome?: number;
1028
+ economicHealthScore?: number;
1029
+ economicHealthTrend?: string;
1030
+ };
1031
+ searchVolume?: {
1032
+ monthlySearchVolume?: number;
1033
+ topKeywords?: Array<{ keyword: string; volume: number }>;
1034
+ yoyChangePct?: number;
1035
+ trendDirection?: string;
1036
+ };
1037
+ }
1038
+
1039
+ /**
1040
+ * IntelligenceData — the single structured blob written to plans.intelligenceData.
1041
+ *
1042
+ * fn-v2 analyze-location.ts writes this after all enrichment tools complete.
1043
+ * All fields are optional — data fills in progressively as tools complete.
1044
+ */
1045
+ export interface IntelligenceData {
1046
+ /** Enriched prospect profile */
1047
+ prospect?: IntelligenceProspect;
1048
+ /** Enriched competitor profiles */
1049
+ competitors?: PlanningCompetitor[];
1050
+ /** Ad intelligence (prospect + competitors) */
1051
+ adIntelligence?: AdIntelligenceEntry[];
1052
+ /** Pixel / tracking detection for prospect site */
1053
+ pixelDetection?: PixelDetectionResult;
1054
+ /** Domain SEO overview for prospect */
1055
+ domainOverview?: DomainOverviewResult;
1056
+ /** Landing page audit for prospect */
1057
+ landingPageAudit?: LandingPageAuditResult;
1058
+ /** SEO keyword/ranking analysis */
1059
+ seoData?: SeoAnalysisResult;
1060
+ /** Social media presence */
1061
+ socialData?: SocialDataResult;
1062
+ /** Strategy brief from LLM analysis */
1063
+ strategyBrief?: StrategyBriefResult;
1064
+ /** Market intelligence (seasonality, benchmarks, scores) */
1065
+ marketContext?: MarketContextResult;
1066
+
1067
+ /** Metadata */
1068
+ enrichedAt?: string;
1069
+ enrichmentVersion?: string;
1070
+ toolsCompleted?: string[];
1071
+ toolsFailed?: string[];
1072
+ }