From 6d6eba75bf765c122567b48414021d4b39b0504f Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 28 Feb 2026 00:59:47 +0100 Subject: [PATCH] feat(prospecting): add complete prospecting module for lead discovery and scoring Migrates scanning pipeline from marketing-.lu-domains app into Orion module. Supports digital (domain scan) and offline (manual capture) lead channels with enrichment, scoring, campaign management, and interaction tracking. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + app/modules/prospecting/__init__.py | 37 ++ app/modules/prospecting/config.py | 34 ++ app/modules/prospecting/definition.py | 165 ++++++++ app/modules/prospecting/docs/database.md | 171 ++++++++ .../prospecting/docs/research-findings.md | 80 ++++ app/modules/prospecting/docs/scoring.md | 110 ++++++ app/modules/prospecting/exceptions.py | 80 ++++ app/modules/prospecting/locales/de.json | 34 ++ app/modules/prospecting/locales/en.json | 34 ++ app/modules/prospecting/locales/fr.json | 34 ++ app/modules/prospecting/locales/lb.json | 34 ++ .../versions/prospecting_001_initial.py | 222 +++++++++++ app/modules/prospecting/models/__init__.py | 47 +++ app/modules/prospecting/models/campaign.py | 83 ++++ app/modules/prospecting/models/interaction.py | 54 +++ .../prospecting/models/performance_profile.py | 64 +++ app/modules/prospecting/models/prospect.py | 82 ++++ .../prospecting/models/prospect_contact.py | 44 +++ .../prospecting/models/prospect_score.py | 46 +++ app/modules/prospecting/models/scan_job.py | 61 +++ .../prospecting/models/tech_profile.py | 51 +++ app/modules/prospecting/routes/__init__.py | 1 + .../prospecting/routes/api/__init__.py | 1 + app/modules/prospecting/routes/api/admin.py | 30 ++ .../prospecting/routes/api/admin_campaigns.py | 116 ++++++ .../routes/api/admin_enrichment.py | 177 +++++++++ .../routes/api/admin_interactions.py | 65 +++ .../prospecting/routes/api/admin_leads.py | 99 +++++ .../prospecting/routes/api/admin_prospects.py | 203 ++++++++++ .../prospecting/routes/api/admin_stats.py | 50 +++ .../prospecting/routes/pages/__init__.py | 1 + app/modules/prospecting/routes/pages/admin.py | 123 ++++++ app/modules/prospecting/schemas/__init__.py | 66 ++++ app/modules/prospecting/schemas/campaign.py | 103 +++++ app/modules/prospecting/schemas/contact.py | 34 ++ app/modules/prospecting/schemas/enrichment.py | 69 ++++ .../prospecting/schemas/interaction.py | 44 +++ .../schemas/performance_profile.py | 33 ++ app/modules/prospecting/schemas/prospect.py | 122 ++++++ app/modules/prospecting/schemas/scan_job.py | 38 ++ app/modules/prospecting/schemas/score.py | 27 ++ .../prospecting/schemas/tech_profile.py | 33 ++ app/modules/prospecting/services/__init__.py | 1 + .../prospecting/services/campaign_service.py | 190 +++++++++ .../services/enrichment_service.py | 369 ++++++++++++++++++ .../services/interaction_service.py | 77 ++++ .../prospecting/services/lead_service.py | 153 ++++++++ .../prospecting/services/prospect_service.py | 235 +++++++++++ .../prospecting/services/scoring_service.py | 253 ++++++++++++ .../prospecting/services/stats_service.py | 99 +++++ .../prospecting/static/admin/js/campaigns.js | 137 +++++++ .../prospecting/static/admin/js/capture.js | 153 ++++++++ .../prospecting/static/admin/js/dashboard.js | 84 ++++ .../prospecting/static/admin/js/leads.js | 107 +++++ .../static/admin/js/prospect-detail.js | 141 +++++++ .../prospecting/static/admin/js/prospects.js | 149 +++++++ .../prospecting/static/admin/js/scan-jobs.js | 87 +++++ app/modules/prospecting/tasks/__init__.py | 1 + app/modules/prospecting/tasks/scan_tasks.py | 325 +++++++++++++++ .../prospecting/admin/campaigns.html | 143 +++++++ .../templates/prospecting/admin/capture.html | 137 +++++++ .../prospecting/admin/dashboard.html | 127 ++++++ .../templates/prospecting/admin/leads.html | 127 ++++++ .../prospecting/admin/prospect-detail.html | 280 +++++++++++++ .../prospecting/admin/prospects.html | 226 +++++++++++ .../prospecting/admin/scan-jobs.html | 94 +++++ app/modules/prospecting/tests/__init__.py | 1 + app/modules/prospecting/tests/conftest.py | 231 +++++++++++ .../prospecting/tests/unit/__init__.py | 1 + .../tests/unit/test_campaign_service.py | 107 +++++ .../tests/unit/test_enrichment_service.py | 98 +++++ .../tests/unit/test_interaction_service.py | 77 ++++ .../tests/unit/test_lead_service.py | 59 +++ .../tests/unit/test_prospect_service.py | 149 +++++++ .../tests/unit/test_scoring_service.py | 89 +++++ .../tests/unit/test_stats_service.py | 36 ++ pyproject.toml | 2 + scripts/validate/base_validator.py | 1 + 79 files changed, 7551 insertions(+) create mode 100644 app/modules/prospecting/__init__.py create mode 100644 app/modules/prospecting/config.py create mode 100644 app/modules/prospecting/definition.py create mode 100644 app/modules/prospecting/docs/database.md create mode 100644 app/modules/prospecting/docs/research-findings.md create mode 100644 app/modules/prospecting/docs/scoring.md create mode 100644 app/modules/prospecting/exceptions.py create mode 100644 app/modules/prospecting/locales/de.json create mode 100644 app/modules/prospecting/locales/en.json create mode 100644 app/modules/prospecting/locales/fr.json create mode 100644 app/modules/prospecting/locales/lb.json create mode 100644 app/modules/prospecting/migrations/versions/prospecting_001_initial.py create mode 100644 app/modules/prospecting/models/__init__.py create mode 100644 app/modules/prospecting/models/campaign.py create mode 100644 app/modules/prospecting/models/interaction.py create mode 100644 app/modules/prospecting/models/performance_profile.py create mode 100644 app/modules/prospecting/models/prospect.py create mode 100644 app/modules/prospecting/models/prospect_contact.py create mode 100644 app/modules/prospecting/models/prospect_score.py create mode 100644 app/modules/prospecting/models/scan_job.py create mode 100644 app/modules/prospecting/models/tech_profile.py create mode 100644 app/modules/prospecting/routes/__init__.py create mode 100644 app/modules/prospecting/routes/api/__init__.py create mode 100644 app/modules/prospecting/routes/api/admin.py create mode 100644 app/modules/prospecting/routes/api/admin_campaigns.py create mode 100644 app/modules/prospecting/routes/api/admin_enrichment.py create mode 100644 app/modules/prospecting/routes/api/admin_interactions.py create mode 100644 app/modules/prospecting/routes/api/admin_leads.py create mode 100644 app/modules/prospecting/routes/api/admin_prospects.py create mode 100644 app/modules/prospecting/routes/api/admin_stats.py create mode 100644 app/modules/prospecting/routes/pages/__init__.py create mode 100644 app/modules/prospecting/routes/pages/admin.py create mode 100644 app/modules/prospecting/schemas/__init__.py create mode 100644 app/modules/prospecting/schemas/campaign.py create mode 100644 app/modules/prospecting/schemas/contact.py create mode 100644 app/modules/prospecting/schemas/enrichment.py create mode 100644 app/modules/prospecting/schemas/interaction.py create mode 100644 app/modules/prospecting/schemas/performance_profile.py create mode 100644 app/modules/prospecting/schemas/prospect.py create mode 100644 app/modules/prospecting/schemas/scan_job.py create mode 100644 app/modules/prospecting/schemas/score.py create mode 100644 app/modules/prospecting/schemas/tech_profile.py create mode 100644 app/modules/prospecting/services/__init__.py create mode 100644 app/modules/prospecting/services/campaign_service.py create mode 100644 app/modules/prospecting/services/enrichment_service.py create mode 100644 app/modules/prospecting/services/interaction_service.py create mode 100644 app/modules/prospecting/services/lead_service.py create mode 100644 app/modules/prospecting/services/prospect_service.py create mode 100644 app/modules/prospecting/services/scoring_service.py create mode 100644 app/modules/prospecting/services/stats_service.py create mode 100644 app/modules/prospecting/static/admin/js/campaigns.js create mode 100644 app/modules/prospecting/static/admin/js/capture.js create mode 100644 app/modules/prospecting/static/admin/js/dashboard.js create mode 100644 app/modules/prospecting/static/admin/js/leads.js create mode 100644 app/modules/prospecting/static/admin/js/prospect-detail.js create mode 100644 app/modules/prospecting/static/admin/js/prospects.js create mode 100644 app/modules/prospecting/static/admin/js/scan-jobs.js create mode 100644 app/modules/prospecting/tasks/__init__.py create mode 100644 app/modules/prospecting/tasks/scan_tasks.py create mode 100644 app/modules/prospecting/templates/prospecting/admin/campaigns.html create mode 100644 app/modules/prospecting/templates/prospecting/admin/capture.html create mode 100644 app/modules/prospecting/templates/prospecting/admin/dashboard.html create mode 100644 app/modules/prospecting/templates/prospecting/admin/leads.html create mode 100644 app/modules/prospecting/templates/prospecting/admin/prospect-detail.html create mode 100644 app/modules/prospecting/templates/prospecting/admin/prospects.html create mode 100644 app/modules/prospecting/templates/prospecting/admin/scan-jobs.html create mode 100644 app/modules/prospecting/tests/__init__.py create mode 100644 app/modules/prospecting/tests/conftest.py create mode 100644 app/modules/prospecting/tests/unit/__init__.py create mode 100644 app/modules/prospecting/tests/unit/test_campaign_service.py create mode 100644 app/modules/prospecting/tests/unit/test_enrichment_service.py create mode 100644 app/modules/prospecting/tests/unit/test_interaction_service.py create mode 100644 app/modules/prospecting/tests/unit/test_lead_service.py create mode 100644 app/modules/prospecting/tests/unit/test_prospect_service.py create mode 100644 app/modules/prospecting/tests/unit/test_scoring_service.py create mode 100644 app/modules/prospecting/tests/unit/test_stats_service.py diff --git a/.gitignore b/.gitignore index b988a066..0439ebd3 100644 --- a/.gitignore +++ b/.gitignore @@ -190,3 +190,6 @@ static/shared/css/tailwind.css # Export files orion_letzshop_export_*.csv exports/ + +# Security audit (needs revamping) +scripts/security-audit/ diff --git a/app/modules/prospecting/__init__.py b/app/modules/prospecting/__init__.py new file mode 100644 index 00000000..d604e5f2 --- /dev/null +++ b/app/modules/prospecting/__init__.py @@ -0,0 +1,37 @@ +# app/modules/prospecting/__init__.py +""" +Prospecting Module - Lead discovery, scoring, and campaign management. + +This is a self-contained module providing: +- Domain scanning and website analysis for Luxembourg businesses +- Offline lead capture (street encounters, networking) +- Opportunity scoring algorithm +- Marketing campaign management tailored by lead type +- Interaction tracking and follow-up reminders + +Module Structure: +- models/ - Database models (prospects, tech profiles, scores, campaigns, etc.) +- services/ - Business logic (enrichment, scoring, lead filtering, campaigns) +- schemas/ - Pydantic DTOs +- routes/ - API and page routes (admin only) +- tasks/ - Celery background tasks for batch scanning +- exceptions.py - Module-specific exceptions +""" + + +def __getattr__(name: str): + """Lazy import module components to avoid circular imports.""" + if name == "prospecting_module": + from app.modules.prospecting.definition import prospecting_module + + return prospecting_module + if name == "get_prospecting_module_with_routers": + from app.modules.prospecting.definition import ( + get_prospecting_module_with_routers, + ) + + return get_prospecting_module_with_routers + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = ["prospecting_module", "get_prospecting_module_with_routers"] diff --git a/app/modules/prospecting/config.py b/app/modules/prospecting/config.py new file mode 100644 index 00000000..e493eb58 --- /dev/null +++ b/app/modules/prospecting/config.py @@ -0,0 +1,34 @@ +# app/modules/prospecting/config.py +""" +Module configuration. + +Environment-based configuration using Pydantic Settings. +Settings are loaded from environment variables with PROSPECTING_ prefix. + +Example: + PROSPECTING_PAGESPEED_API_KEY=your_key +""" +from pydantic_settings import BaseSettings + + +class ModuleConfig(BaseSettings): + """Configuration for prospecting module.""" + + # PageSpeed Insights API key (optional, free 25k/day without key) + pagespeed_api_key: str = "" + + # HTTP request timeout in seconds + http_timeout: int = 10 + + # Batch operation limits + batch_size: int = 100 + + # Max concurrent HTTP requests for batch scanning + max_concurrent_requests: int = 10 + + model_config = {"env_prefix": "PROSPECTING_"} + + +# Export for auto-discovery +config_class = ModuleConfig +config = ModuleConfig() diff --git a/app/modules/prospecting/definition.py b/app/modules/prospecting/definition.py new file mode 100644 index 00000000..e3e2a100 --- /dev/null +++ b/app/modules/prospecting/definition.py @@ -0,0 +1,165 @@ +# app/modules/prospecting/definition.py +""" +Prospecting module definition. + +Lead discovery, scoring, and campaign management for Luxembourg businesses. +Supports digital (domain scanning) and offline (street/networking) lead channels. +Admin-only module for superadmin and platform admin users. +""" + +from app.modules.base import ( + MenuItemDefinition, + MenuSectionDefinition, + ModuleDefinition, + PermissionDefinition, +) +from app.modules.enums import FrontendType + + +def _get_admin_api_router(): + """Lazy import of admin API router to avoid circular imports.""" + from app.modules.prospecting.routes.api.admin import router + + return router + + +def _get_admin_page_router(): + """Lazy import of admin page router to avoid circular imports.""" + from app.modules.prospecting.routes.pages.admin import router + + return router + + +prospecting_module = ModuleDefinition( + code="prospecting", + name="Prospecting", + description="Lead discovery, scoring, and campaign management for Luxembourg businesses.", + version="1.0.0", + is_core=False, + is_self_contained=True, + permissions=[ + PermissionDefinition( + id="prospecting.view", + label_key="prospecting.permissions.view", + description_key="prospecting.permissions.view_desc", + category="prospecting", + ), + PermissionDefinition( + id="prospecting.manage", + label_key="prospecting.permissions.manage", + description_key="prospecting.permissions.manage_desc", + category="prospecting", + ), + PermissionDefinition( + id="prospecting.scan", + label_key="prospecting.permissions.scan", + description_key="prospecting.permissions.scan_desc", + category="prospecting", + ), + PermissionDefinition( + id="prospecting.campaigns", + label_key="prospecting.permissions.campaigns", + description_key="prospecting.permissions.campaigns_desc", + category="prospecting", + ), + PermissionDefinition( + id="prospecting.export", + label_key="prospecting.permissions.export", + description_key="prospecting.permissions.export_desc", + category="prospecting", + ), + ], + features=[ + "domain_scanning", + "offline_capture", + "opportunity_scoring", + "campaign_management", + "lead_export", + ], + menu_items={ + FrontendType.ADMIN: [ + "prospecting-dashboard", + "prospects", + "leads", + "capture", + "scan-jobs", + "campaigns", + ], + }, + menus={ + FrontendType.ADMIN: [ + MenuSectionDefinition( + id="prospecting", + label_key="prospecting.menu.prospecting", + icon="target", + order=60, + items=[ + MenuItemDefinition( + id="prospecting-dashboard", + label_key="prospecting.menu.dashboard", + icon="chart-bar", + route="/admin/prospecting", + order=1, + ), + MenuItemDefinition( + id="prospects", + label_key="prospecting.menu.prospects", + icon="globe", + route="/admin/prospecting/prospects", + order=5, + ), + MenuItemDefinition( + id="leads", + label_key="prospecting.menu.leads", + icon="target", + route="/admin/prospecting/leads", + order=10, + ), + MenuItemDefinition( + id="capture", + label_key="prospecting.menu.capture", + icon="device-mobile", + route="/admin/prospecting/capture", + order=15, + ), + MenuItemDefinition( + id="scan-jobs", + label_key="prospecting.menu.scan_jobs", + icon="radar", + route="/admin/prospecting/scan-jobs", + order=20, + ), + MenuItemDefinition( + id="campaigns", + label_key="prospecting.menu.campaigns", + icon="mail", + route="/admin/prospecting/campaigns", + order=25, + ), + ], + ), + ], + }, + migrations_path="migrations", + services_path="app.modules.prospecting.services", + models_path="app.modules.prospecting.models", + schemas_path="app.modules.prospecting.schemas", + exceptions_path="app.modules.prospecting.exceptions", + templates_path="templates", + locales_path="locales", + tasks_path="app.modules.prospecting.tasks", +) + + +def get_prospecting_module_with_routers() -> ModuleDefinition: + """ + Get prospecting module with routers attached. + + Attaches routers lazily to avoid circular imports during module initialization. + """ + prospecting_module.admin_api_router = _get_admin_api_router() + prospecting_module.admin_page_router = _get_admin_page_router() + return prospecting_module + + +__all__ = ["prospecting_module", "get_prospecting_module_with_routers"] diff --git a/app/modules/prospecting/docs/database.md b/app/modules/prospecting/docs/database.md new file mode 100644 index 00000000..daf2d5c5 --- /dev/null +++ b/app/modules/prospecting/docs/database.md @@ -0,0 +1,171 @@ +# Database Schema + +## Entity Relationship Diagram + +``` +┌─────────────────────┐ ┌────────────────────────┐ +│ prospects │────<│ prospect_tech_profiles │ +├─────────────────────┤ ├────────────────────────┤ +│ id │ │ id │ +│ channel │ │ prospect_id (FK) │ +│ business_name │ │ cms, server │ +│ domain_name │ │ hosting_provider │ +│ status │ │ js_framework, cdn │ +│ source │ │ analytics │ +│ has_website │ │ ecommerce_platform │ +│ uses_https │ │ tech_stack_json (JSON) │ +│ ... │ └────────────────────────┘ +└─────────────────────┘ + │ + │ ┌──────────────────────────────┐ + └──────────────<│ prospect_performance_profiles │ + │ ├──────────────────────────────┤ + │ │ id │ + │ │ prospect_id (FK) │ + │ │ performance_score (0-100) │ + │ │ accessibility_score │ + │ │ seo_score │ + │ │ FCP, LCP, TBT, CLS │ + │ │ is_mobile_friendly │ + │ └──────────────────────────────┘ + │ + │ ┌───────────────────────┐ + └──────────────<│ prospect_scores │ + │ ├───────────────────────┤ + │ │ id │ + │ │ prospect_id (FK) │ + │ │ score (0-100) │ + │ │ technical_health_score│ + │ │ modernity_score │ + │ │ business_value_score │ + │ │ engagement_score │ + │ │ reason_flags (JSON) │ + │ │ lead_tier │ + │ └───────────────────────┘ + │ + │ ┌───────────────────────┐ + └──────────────<│ prospect_contacts │ + │ ├───────────────────────┤ + │ │ id │ + │ │ prospect_id (FK) │ + │ │ contact_type │ + │ │ value │ + │ │ source_url │ + │ │ is_primary │ + │ └───────────────────────┘ + │ + │ ┌───────────────────────┐ + └──────────────<│ prospect_interactions │ + │ ├───────────────────────┤ + │ │ id │ + │ │ prospect_id (FK) │ + │ │ interaction_type │ + │ │ subject, notes │ + │ │ outcome │ + │ │ next_action │ + │ │ next_action_date │ + │ │ created_by_user_id │ + │ └───────────────────────┘ + │ + │ ┌───────────────────────┐ + └──────────────<│ prospect_scan_jobs │ + ├───────────────────────┤ + │ id │ + │ job_type │ + │ status │ + │ total_items │ + │ processed_items │ + │ celery_task_id │ + └───────────────────────┘ + +┌──────────────────────┐ ┌──────────────────┐ +│ campaign_templates │────<│ campaign_sends │ +├──────────────────────┤ ├──────────────────┤ +│ id │ │ id │ +│ name │ │ template_id (FK) │ +│ lead_type │ │ prospect_id (FK) │ +│ channel │ │ channel │ +│ language │ │ rendered_subject │ +│ subject_template │ │ rendered_body │ +│ body_template │ │ status │ +│ is_active │ │ sent_at │ +└──────────────────────┘ │ sent_by_user_id │ + └──────────────────┘ +``` + +## Tables + +### prospects + +Central table for all leads — both digital (domain-based) and offline (in-person). + +| Column | Type | Description | +|--------|------|-------------| +| id | INTEGER PK | Auto-increment | +| channel | ENUM(digital, offline) | How the lead was discovered | +| business_name | VARCHAR(255) | Required for offline | +| domain_name | VARCHAR(255) | Required for digital, unique | +| status | ENUM | pending, active, inactive, parked, error, contacted, converted | +| source | VARCHAR(100) | e.g. "domain_scan", "networking_event", "street" | +| has_website | BOOLEAN | Determined by HTTP check | +| uses_https | BOOLEAN | SSL status | +| http_status_code | INTEGER | Last HTTP response | +| address | VARCHAR(500) | Physical address (offline) | +| city | VARCHAR(100) | City | +| postal_code | VARCHAR(10) | Postal code | +| country | VARCHAR(2) | Default "LU" | +| notes | TEXT | Free-form notes | +| tags | JSON | Flexible tagging | +| captured_by_user_id | INTEGER FK | Who captured this lead | +| location_lat / location_lng | FLOAT | GPS from mobile capture | +| last_*_at | DATETIME | Timestamps for each scan type | + +### prospect_tech_profiles + +Technology stack detection results. One per prospect. + +| Column | Type | Description | +|--------|------|-------------| +| cms | VARCHAR(100) | WordPress, Drupal, Joomla, etc. | +| server | VARCHAR(100) | Nginx, Apache | +| hosting_provider | VARCHAR(100) | Hosting company | +| cdn | VARCHAR(100) | CDN provider | +| js_framework | VARCHAR(100) | React, Vue, Angular, jQuery | +| analytics | VARCHAR(200) | Google Analytics, Matomo, etc. | +| ecommerce_platform | VARCHAR(100) | Shopify, WooCommerce, etc. | +| tech_stack_json | JSON | Full detection results | + +### prospect_performance_profiles + +Lighthouse audit results. One per prospect. + +| Column | Type | Description | +|--------|------|-------------| +| performance_score | INTEGER | 0-100 | +| accessibility_score | INTEGER | 0-100 | +| seo_score | INTEGER | 0-100 | +| first_contentful_paint_ms | INTEGER | FCP | +| largest_contentful_paint_ms | INTEGER | LCP | +| total_blocking_time_ms | INTEGER | TBT | +| cumulative_layout_shift | FLOAT | CLS | +| is_mobile_friendly | BOOLEAN | Mobile test | + +### prospect_scores + +Calculated opportunity scores. One per prospect. See [scoring.md](scoring.md) for algorithm details. + +### prospect_contacts + +Scraped or manually entered contact info. Many per prospect. + +### prospect_interactions + +CRM-style interaction log. Many per prospect. Types: note, call, email_sent, email_received, meeting, visit, sms, proposal_sent. + +### prospect_scan_jobs + +Background job tracking for batch operations. + +### campaign_templates / campaign_sends + +Marketing campaign templates and send tracking. Templates support placeholders like `{business_name}`, `{domain}`, `{score}`, `{issues}`. diff --git a/app/modules/prospecting/docs/research-findings.md b/app/modules/prospecting/docs/research-findings.md new file mode 100644 index 00000000..085cdc28 --- /dev/null +++ b/app/modules/prospecting/docs/research-findings.md @@ -0,0 +1,80 @@ +# .lu Domain Lead Generation — Research Findings + +Research on data sources, APIs, legal requirements, and cost analysis for the prospecting module. + +## 1. Data Sources for .lu Domains + +The official .lu registry (DNS-LU / RESTENA) does **not** publish zone files. All providers use web crawling to discover domains, so no list is 100% complete. Expect 70-80% coverage. + +### Providers + +| Provider | Domains | Price | Format | Notes | +|----------|---------|-------|--------|-------| +| NetworksDB | ~70,000 | $5 | Zipped text | Best value, one-time purchase | +| DomainMetaData | Varies | $9.90/mo | CSV | Daily updates | +| Webatla | ~75,000 | Unknown | CSV | Good coverage | + +## 2. Technical APIs — Cost Analysis + +### Technology Detection + +| Service | Free Tier | Notes | +|---------|-----------|-------| +| CRFT Lookup | Unlimited | Budget option, includes Lighthouse | +| Wappalyzer | 50/month | Most accurate | +| WhatCMS | Free lookups | CMS-only | + +**Approach used**: Custom HTML parsing for CMS, JS framework, analytics, and server detection (no external API dependency). + +### Performance Audits + +PageSpeed Insights API — **free**, 25,000 queries/day, 400/100 seconds. + +### SSL Checks + +Simple HTTPS connectivity check (fast). SSL Labs API available for deep analysis of high-priority leads. + +### WHOIS + +Due to GDPR, .lu WHOIS data for private individuals is hidden. Only owner type and country visible. Contact info scraped from websites instead. + +## 3. Legal — Luxembourg & GDPR + +### B2B Cold Email Rules + +Luxembourg has **no specific B2B cold email restrictions** per Article 11(1) of the Electronic Privacy Act (applies only to natural persons). + +**Requirements**: +1. Identify yourself clearly (company name, address) +2. Provide opt-out mechanism in every email +3. Message must relate to recipient's business +4. Store contact data securely +5. Only contact businesses, not private individuals + +**Legal basis**: Legitimate interest (GDPR Art. 6(1)(f)) + +### GDPR Penalties + +Fines up to EUR 20 million or 4% of global revenue for violations. + +**Key violations to avoid**: +- Emailing private individuals without consent +- No opt-out mechanism +- Holding personal data longer than necessary + +### Recommendation + +- Focus on `info@`, `contact@`, and business role emails +- Always include unsubscribe link +- Document legitimate interest basis + +## 4. Cost Summary + +| Item | Cost | Type | +|------|------|------| +| Domain list (NetworksDB) | $5 | One-time | +| PageSpeed API | Free | Ongoing | +| Contact scraping | Free | Self-hosted | +| Tech detection | Free | Self-hosted | + +Working MVP costs under $25 total. diff --git a/app/modules/prospecting/docs/scoring.md b/app/modules/prospecting/docs/scoring.md new file mode 100644 index 00000000..015140cd --- /dev/null +++ b/app/modules/prospecting/docs/scoring.md @@ -0,0 +1,110 @@ +# Opportunity Scoring Model + +## Overview + +The scoring model assigns each prospect a score from 0-100 based on the opportunity potential for offering web services. Higher scores indicate better leads. The model supports two channels: **digital** (domain-based) and **offline** (in-person discovery). + +## Score Components — Digital Channel + +### Technical Health (Max 40 points) + +Issues that indicate immediate opportunities: + +| Issue | Points | Condition | +|-------|--------|-----------| +| No SSL | 15 | `uses_https = false` | +| Very Slow | 15 | `performance_score < 30` | +| Slow | 10 | `performance_score < 50` | +| Moderate Speed | 5 | `performance_score < 70` | +| Not Mobile Friendly | 10 | `is_mobile_friendly = false` | + +### Modernity / Stack (Max 25 points) + +Outdated technology stack: + +| Issue | Points | Condition | +|-------|--------|-----------| +| Outdated CMS | 15 | CMS is Drupal, Joomla, or TYPO3 | +| Unknown CMS | 5 | No CMS detected but has website | +| Legacy JavaScript | 5 | Uses jQuery without modern framework | +| No Analytics | 5 | No Google Analytics or similar | + +### Business Value (Max 25 points) + +Indicators of business potential: + +| Factor | Points | Condition | +|--------|--------|-----------| +| Has Website | 10 | Active website exists | +| Has E-commerce | 10 | E-commerce platform detected | +| Short Domain | 5 | Domain name <= 15 characters | + +### Engagement Potential (Max 10 points) + +Ability to contact the business: + +| Factor | Points | Condition | +|--------|--------|-----------| +| Has Contacts | 5 | Any contact info found | +| Has Email | 3 | Email address found | +| Has Phone | 2 | Phone number found | + +## Score Components — Offline Channel + +Offline leads have a simplified scoring model based on the information captured during in-person encounters: + +| Scenario | Technical Health | Modernity | Business Value | Engagement | Total | +|----------|-----------------|-----------|----------------|------------|-------| +| No website at all | 30 | 20 | 20 | 0 | **70** (top_priority) | +| Uses gmail/free email | +0 | +10 | +0 | +0 | +10 | +| Met in person | +0 | +0 | +0 | +5 | +5 | +| Has email contact | +0 | +0 | +0 | +3 | +3 | +| Has phone contact | +0 | +0 | +0 | +2 | +2 | + +A business with no website met in person with contact info scores: 70 + 5 + 3 + 2 = **80** (top_priority). + +## Lead Tiers + +Based on the total score: + +| Tier | Score Range | Description | +|------|-------------|-------------| +| `top_priority` | 70-100 | Best leads, multiple issues or no website at all | +| `quick_win` | 50-69 | Good leads, 1-2 easy fixes | +| `strategic` | 30-49 | Moderate potential | +| `low_priority` | 0-29 | Low opportunity | + +## Reason Flags + +Each score includes `reason_flags` that explain why points were awarded: + +```json +{ + "score": 78, + "reason_flags": ["no_ssl", "slow", "outdated_cms"], + "lead_tier": "top_priority" +} +``` + +Common flags (digital): +- `no_ssl` — Missing HTTPS +- `very_slow` — Performance score < 30 +- `slow` — Performance score < 50 +- `not_mobile_friendly` — Fails mobile tests +- `outdated_cms` — Using old CMS +- `legacy_js` — Using jQuery only +- `no_analytics` — No tracking installed + +Offline-specific flags: +- `no_website` — Business has no website +- `uses_gmail` — Uses free email provider +- `met_in_person` — Lead captured in person (warm lead) + +## Customizing the Model + +The scoring logic is in `app/modules/prospecting/services/scoring_service.py`. You can adjust: + +1. **Point values** — Change weights for different issues +2. **Thresholds** — Adjust performance score cutoffs +3. **Conditions** — Add new scoring criteria +4. **Tier boundaries** — Change score ranges for tiers diff --git a/app/modules/prospecting/exceptions.py b/app/modules/prospecting/exceptions.py new file mode 100644 index 00000000..9e13afa1 --- /dev/null +++ b/app/modules/prospecting/exceptions.py @@ -0,0 +1,80 @@ +# app/modules/prospecting/exceptions.py +""" +Prospecting module exceptions. +""" + +from app.exceptions.base import ( + BusinessLogicException, + ExternalServiceException, + ResourceNotFoundException, +) + + +class ProspectNotFoundException(ResourceNotFoundException): # noqa: MOD025 + """Raised when a prospect is not found.""" + + def __init__(self, identifier: str): + super().__init__( + resource_type="Prospect", + identifier=identifier, + ) + + +class ScanJobNotFoundException(ResourceNotFoundException): # noqa: MOD025 + """Raised when a scan job is not found.""" + + def __init__(self, identifier: str): + super().__init__( + resource_type="ScanJob", + identifier=identifier, + ) + + +class CampaignTemplateNotFoundException(ResourceNotFoundException): # noqa: MOD025 + """Raised when a campaign template is not found.""" + + def __init__(self, identifier: str): + super().__init__( + resource_type="CampaignTemplate", + identifier=identifier, + ) + + +class DuplicateDomainException(BusinessLogicException): # noqa: MOD025 + """Raised when trying to create a prospect with a domain that already exists.""" + + def __init__(self, domain_name: str): + super().__init__( + message=f"A prospect with domain '{domain_name}' already exists", + error_code="DUPLICATE_DOMAIN", + ) + + +class ScanFailedException(ExternalServiceException): # noqa: MOD025 + """Raised when an enrichment scan fails.""" + + def __init__(self, scan_type: str, domain: str, reason: str): + super().__init__( + message=f"{scan_type} scan failed for {domain}: {reason}", + service_name=f"prospecting_{scan_type}", + ) + + +class CampaignRenderException(BusinessLogicException): # noqa: MOD025 + """Raised when campaign template rendering fails.""" + + def __init__(self, template_id: int, reason: str): + super().__init__( + message=f"Failed to render campaign template {template_id}: {reason}", + error_code="CAMPAIGN_RENDER_FAILED", + ) + + +__all__ = [ + "ProspectNotFoundException", + "ScanJobNotFoundException", + "CampaignTemplateNotFoundException", + "DuplicateDomainException", + "ScanFailedException", + "CampaignRenderException", +] diff --git a/app/modules/prospecting/locales/de.json b/app/modules/prospecting/locales/de.json new file mode 100644 index 00000000..225f5b6b --- /dev/null +++ b/app/modules/prospecting/locales/de.json @@ -0,0 +1,34 @@ +{ + "prospecting": { + "page_title": "Akquise", + "dashboard_title": "Akquise Dashboard", + "prospects": "Interessenten", + "leads": "Leads", + "capture": "Schnellerfassung", + "scan_jobs": "Scan-Aufträge", + "campaigns": "Kampagnen", + "loading": "Laden...", + "error_loading": "Fehler beim Laden der Daten" + }, + "permissions": { + "view": "Interessenten anzeigen", + "view_desc": "Zugriff auf Interessenten, Leads und Statistiken", + "manage": "Interessenten verwalten", + "manage_desc": "Interessenten erstellen, bearbeiten und löschen", + "scan": "Scans ausführen", + "scan_desc": "Domain-Scan und Anreicherungsoperationen durchführen", + "campaigns": "Kampagnen verwalten", + "campaigns_desc": "Marketingkampagnen erstellen und versenden", + "export": "Daten exportieren", + "export_desc": "Leads als CSV exportieren" + }, + "menu": { + "prospecting": "Akquise", + "dashboard": "Dashboard", + "prospects": "Interessenten", + "leads": "Leads", + "capture": "Schnellerfassung", + "scan_jobs": "Scan-Aufträge", + "campaigns": "Kampagnen" + } +} diff --git a/app/modules/prospecting/locales/en.json b/app/modules/prospecting/locales/en.json new file mode 100644 index 00000000..0143111b --- /dev/null +++ b/app/modules/prospecting/locales/en.json @@ -0,0 +1,34 @@ +{ + "prospecting": { + "page_title": "Prospecting", + "dashboard_title": "Prospecting Dashboard", + "prospects": "Prospects", + "leads": "Leads", + "capture": "Quick Capture", + "scan_jobs": "Scan Jobs", + "campaigns": "Campaigns", + "loading": "Loading...", + "error_loading": "Failed to load data" + }, + "permissions": { + "view": "View Prospects", + "view_desc": "View prospect data, leads, and statistics", + "manage": "Manage Prospects", + "manage_desc": "Create, edit, and delete prospects", + "scan": "Run Scans", + "scan_desc": "Execute domain scanning and enrichment operations", + "campaigns": "Manage Campaigns", + "campaigns_desc": "Create and send marketing campaigns", + "export": "Export Data", + "export_desc": "Export leads to CSV" + }, + "menu": { + "prospecting": "Prospecting", + "dashboard": "Dashboard", + "prospects": "Prospects", + "leads": "Leads", + "capture": "Quick Capture", + "scan_jobs": "Scan Jobs", + "campaigns": "Campaigns" + } +} diff --git a/app/modules/prospecting/locales/fr.json b/app/modules/prospecting/locales/fr.json new file mode 100644 index 00000000..8cccfbcd --- /dev/null +++ b/app/modules/prospecting/locales/fr.json @@ -0,0 +1,34 @@ +{ + "prospecting": { + "page_title": "Prospection", + "dashboard_title": "Tableau de bord Prospection", + "prospects": "Prospects", + "leads": "Leads", + "capture": "Capture rapide", + "scan_jobs": "Tâches de scan", + "campaigns": "Campagnes", + "loading": "Chargement...", + "error_loading": "Erreur lors du chargement des données" + }, + "permissions": { + "view": "Voir les prospects", + "view_desc": "Voir les données des prospects, leads et statistiques", + "manage": "Gérer les prospects", + "manage_desc": "Créer, modifier et supprimer des prospects", + "scan": "Lancer les scans", + "scan_desc": "Exécuter les opérations de scan et d'enrichissement des domaines", + "campaigns": "Gérer les campagnes", + "campaigns_desc": "Créer et envoyer des campagnes marketing", + "export": "Exporter les données", + "export_desc": "Exporter les leads en CSV" + }, + "menu": { + "prospecting": "Prospection", + "dashboard": "Tableau de bord", + "prospects": "Prospects", + "leads": "Leads", + "capture": "Capture rapide", + "scan_jobs": "Tâches de scan", + "campaigns": "Campagnes" + } +} diff --git a/app/modules/prospecting/locales/lb.json b/app/modules/prospecting/locales/lb.json new file mode 100644 index 00000000..6953f85e --- /dev/null +++ b/app/modules/prospecting/locales/lb.json @@ -0,0 +1,34 @@ +{ + "prospecting": { + "page_title": "Prospektioun", + "dashboard_title": "Prospektioun Dashboard", + "prospects": "Interessenten", + "leads": "Leads", + "capture": "Schnell Erfaassung", + "scan_jobs": "Scan Aufträg", + "campaigns": "Kampagnen", + "loading": "Lueden...", + "error_loading": "Feeler beim Luede vun den Donnéeën" + }, + "permissions": { + "view": "Interessente kucken", + "view_desc": "Zougang zu Interessenten, Leads an Statistiken", + "manage": "Interessente geréieren", + "manage_desc": "Interessenten erstellen, änneren a läschen", + "scan": "Scanne lafen", + "scan_desc": "Domain-Scan an Enrichment Operatiounen duerchféieren", + "campaigns": "Kampagne geréieren", + "campaigns_desc": "Marketing Kampagnen erstellen a schécken", + "export": "Daten exportéieren", + "export_desc": "Leads als CSV exportéieren" + }, + "menu": { + "prospecting": "Prospektioun", + "dashboard": "Dashboard", + "prospects": "Interessenten", + "leads": "Leads", + "capture": "Schnell Erfaassung", + "scan_jobs": "Scan Aufträg", + "campaigns": "Kampagnen" + } +} diff --git a/app/modules/prospecting/migrations/versions/prospecting_001_initial.py b/app/modules/prospecting/migrations/versions/prospecting_001_initial.py new file mode 100644 index 00000000..f0775aeb --- /dev/null +++ b/app/modules/prospecting/migrations/versions/prospecting_001_initial.py @@ -0,0 +1,222 @@ +"""prospecting: initial tables for lead discovery and campaign management + +Revision ID: prospecting_001 +Revises: None +Create Date: 2026-02-27 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "prospecting_001" +down_revision = None +branch_labels = ("prospecting",) +depends_on = None + + +def upgrade() -> None: + # --- prospects --- + op.create_table( + "prospects", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("channel", sa.String(10), nullable=False, server_default="digital"), + sa.Column("business_name", sa.String(255), nullable=True), + sa.Column("domain_name", sa.String(255), nullable=True, unique=True, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("source", sa.String(100), nullable=True), + sa.Column("has_website", sa.Boolean(), nullable=True), + sa.Column("uses_https", sa.Boolean(), nullable=True), + sa.Column("http_status_code", sa.Integer(), nullable=True), + sa.Column("redirect_url", sa.Text(), nullable=True), + sa.Column("address", sa.String(500), nullable=True), + sa.Column("city", sa.String(100), nullable=True), + sa.Column("postal_code", sa.String(10), nullable=True), + sa.Column("country", sa.String(2), nullable=False, server_default="LU"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("tags", sa.Text(), nullable=True), + sa.Column("captured_by_user_id", sa.Integer(), nullable=True), + sa.Column("location_lat", sa.Float(), nullable=True), + sa.Column("location_lng", sa.Float(), nullable=True), + sa.Column("last_http_check_at", sa.DateTime(), nullable=True), + sa.Column("last_tech_scan_at", sa.DateTime(), nullable=True), + sa.Column("last_perf_scan_at", sa.DateTime(), nullable=True), + sa.Column("last_contact_scrape_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- prospect_tech_profiles --- + op.create_table( + "prospect_tech_profiles", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("cms", sa.String(100), nullable=True), + sa.Column("cms_version", sa.String(50), nullable=True), + sa.Column("server", sa.String(100), nullable=True), + sa.Column("server_version", sa.String(50), nullable=True), + sa.Column("hosting_provider", sa.String(100), nullable=True), + sa.Column("cdn", sa.String(100), nullable=True), + sa.Column("has_valid_cert", sa.Boolean(), nullable=True), + sa.Column("cert_issuer", sa.String(200), nullable=True), + sa.Column("cert_expires_at", sa.DateTime(), nullable=True), + sa.Column("js_framework", sa.String(100), nullable=True), + sa.Column("analytics", sa.String(200), nullable=True), + sa.Column("tag_manager", sa.String(100), nullable=True), + sa.Column("ecommerce_platform", sa.String(100), nullable=True), + sa.Column("tech_stack_json", sa.Text(), nullable=True), + sa.Column("scan_source", sa.String(50), nullable=True), + sa.Column("scan_error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- prospect_performance_profiles --- + op.create_table( + "prospect_performance_profiles", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("performance_score", sa.Integer(), nullable=True), + sa.Column("accessibility_score", sa.Integer(), nullable=True), + sa.Column("best_practices_score", sa.Integer(), nullable=True), + sa.Column("seo_score", sa.Integer(), nullable=True), + sa.Column("first_contentful_paint_ms", sa.Integer(), nullable=True), + sa.Column("largest_contentful_paint_ms", sa.Integer(), nullable=True), + sa.Column("total_blocking_time_ms", sa.Integer(), nullable=True), + sa.Column("cumulative_layout_shift", sa.Float(), nullable=True), + sa.Column("speed_index", sa.Integer(), nullable=True), + sa.Column("time_to_interactive_ms", sa.Integer(), nullable=True), + sa.Column("is_mobile_friendly", sa.Boolean(), nullable=True), + sa.Column("viewport_configured", sa.Boolean(), nullable=True), + sa.Column("font_size_ok", sa.Boolean(), nullable=True), + sa.Column("tap_targets_ok", sa.Boolean(), nullable=True), + sa.Column("total_bytes", sa.Integer(), nullable=True), + sa.Column("html_bytes", sa.Integer(), nullable=True), + sa.Column("css_bytes", sa.Integer(), nullable=True), + sa.Column("js_bytes", sa.Integer(), nullable=True), + sa.Column("image_bytes", sa.Integer(), nullable=True), + sa.Column("font_bytes", sa.Integer(), nullable=True), + sa.Column("total_requests", sa.Integer(), nullable=True), + sa.Column("js_requests", sa.Integer(), nullable=True), + sa.Column("css_requests", sa.Integer(), nullable=True), + sa.Column("image_requests", sa.Integer(), nullable=True), + sa.Column("lighthouse_json", sa.Text(), nullable=True), + sa.Column("scan_strategy", sa.String(20), nullable=True), + sa.Column("scan_error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- prospect_scores --- + op.create_table( + "prospect_scores", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("score", sa.Integer(), nullable=False, server_default="0", index=True), + sa.Column("technical_health_score", sa.Integer(), nullable=False, server_default="0"), + sa.Column("modernity_score", sa.Integer(), nullable=False, server_default="0"), + sa.Column("business_value_score", sa.Integer(), nullable=False, server_default="0"), + sa.Column("engagement_score", sa.Integer(), nullable=False, server_default="0"), + sa.Column("reason_flags", sa.Text(), nullable=True), + sa.Column("score_breakdown", sa.Text(), nullable=True), + sa.Column("lead_tier", sa.String(20), nullable=True, index=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- prospect_contacts --- + op.create_table( + "prospect_contacts", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("contact_type", sa.String(20), nullable=False), + sa.Column("value", sa.String(500), nullable=False), + sa.Column("label", sa.String(100), nullable=True), + sa.Column("source_url", sa.Text(), nullable=True), + sa.Column("source_element", sa.String(100), nullable=True), + sa.Column("is_validated", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("validation_error", sa.Text(), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- prospect_scan_jobs --- + op.create_table( + "prospect_scan_jobs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("job_type", sa.String(30), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("total_items", sa.Integer(), nullable=False, server_default="0"), + sa.Column("processed_items", sa.Integer(), nullable=False, server_default="0"), + sa.Column("failed_items", sa.Integer(), nullable=False, server_default="0"), + sa.Column("skipped_items", sa.Integer(), nullable=False, server_default="0"), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("config", sa.Text(), nullable=True), + sa.Column("result_summary", sa.Text(), nullable=True), + sa.Column("error_log", sa.Text(), nullable=True), + sa.Column("source_file", sa.String(500), nullable=True), + sa.Column("celery_task_id", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- prospect_interactions --- + op.create_table( + "prospect_interactions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("interaction_type", sa.String(20), nullable=False), + sa.Column("subject", sa.String(255), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("outcome", sa.String(20), nullable=True), + sa.Column("next_action", sa.String(255), nullable=True), + sa.Column("next_action_date", sa.Date(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- campaign_templates --- + op.create_table( + "campaign_templates", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("lead_type", sa.String(30), nullable=False), + sa.Column("channel", sa.String(20), nullable=False, server_default="email"), + sa.Column("language", sa.String(5), nullable=False, server_default="fr"), + sa.Column("subject_template", sa.String(500), nullable=True), + sa.Column("body_template", sa.Text(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- campaign_sends --- + op.create_table( + "campaign_sends", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("template_id", sa.Integer(), sa.ForeignKey("campaign_templates.id", ondelete="SET NULL"), nullable=True), + sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("channel", sa.String(20), nullable=False), + sa.Column("rendered_subject", sa.String(500), nullable=True), + sa.Column("rendered_body", sa.Text(), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="draft"), + sa.Column("sent_at", sa.DateTime(), nullable=True), + sa.Column("sent_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("campaign_sends") + op.drop_table("campaign_templates") + op.drop_table("prospect_interactions") + op.drop_table("prospect_scan_jobs") + op.drop_table("prospect_contacts") + op.drop_table("prospect_scores") + op.drop_table("prospect_performance_profiles") + op.drop_table("prospect_tech_profiles") + op.drop_table("prospects") diff --git a/app/modules/prospecting/models/__init__.py b/app/modules/prospecting/models/__init__.py new file mode 100644 index 00000000..f85c4f84 --- /dev/null +++ b/app/modules/prospecting/models/__init__.py @@ -0,0 +1,47 @@ +# app/modules/prospecting/models/__init__.py +from app.modules.prospecting.models.campaign import ( + CampaignChannel, + CampaignSend, + CampaignSendStatus, + CampaignTemplate, + LeadType, +) +from app.modules.prospecting.models.interaction import ( + InteractionOutcome, + InteractionType, + ProspectInteraction, +) +from app.modules.prospecting.models.performance_profile import ( + ProspectPerformanceProfile, +) +from app.modules.prospecting.models.prospect import ( + Prospect, + ProspectChannel, + ProspectStatus, +) +from app.modules.prospecting.models.prospect_contact import ContactType, ProspectContact +from app.modules.prospecting.models.prospect_score import ProspectScore +from app.modules.prospecting.models.scan_job import JobStatus, JobType, ProspectScanJob +from app.modules.prospecting.models.tech_profile import ProspectTechProfile + +__all__ = [ + "Prospect", + "ProspectChannel", + "ProspectStatus", + "ProspectTechProfile", + "ProspectPerformanceProfile", + "ProspectScore", + "ProspectContact", + "ContactType", + "ProspectScanJob", + "JobType", + "JobStatus", + "ProspectInteraction", + "InteractionType", + "InteractionOutcome", + "CampaignTemplate", + "CampaignSend", + "CampaignChannel", + "CampaignSendStatus", + "LeadType", +] diff --git a/app/modules/prospecting/models/campaign.py b/app/modules/prospecting/models/campaign.py new file mode 100644 index 00000000..8b9a4c41 --- /dev/null +++ b/app/modules/prospecting/models/campaign.py @@ -0,0 +1,83 @@ +# app/modules/prospecting/models/campaign.py +""" +Campaign templates and send tracking. + +Templates are tailored by lead type (no_website, bad_website, etc.) +with support for multiple languages and delivery channels. +""" + +import enum + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, +) + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class LeadType(str, enum.Enum): + NO_WEBSITE = "no_website" + BAD_WEBSITE = "bad_website" + GMAIL_ONLY = "gmail_only" + SECURITY_ISSUES = "security_issues" + PERFORMANCE_ISSUES = "performance_issues" + OUTDATED_CMS = "outdated_cms" + GENERAL = "general" + + +class CampaignChannel(str, enum.Enum): + EMAIL = "email" + LETTER = "letter" + PHONE_SCRIPT = "phone_script" + + +class CampaignSendStatus(str, enum.Enum): + DRAFT = "draft" + SENT = "sent" + DELIVERED = "delivered" + OPENED = "opened" + BOUNCED = "bounced" + REPLIED = "replied" + + +class CampaignTemplate(Base, TimestampMixin): + """A reusable marketing campaign template.""" + + __tablename__ = "campaign_templates" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + lead_type = Column(Enum(LeadType), nullable=False) + channel = Column(Enum(CampaignChannel), nullable=False, default=CampaignChannel.EMAIL) + language = Column(String(5), nullable=False, default="fr") + + subject_template = Column(String(500), nullable=True) + body_template = Column(Text, nullable=False) + + is_active = Column(Boolean, nullable=False, default=True) + + +class CampaignSend(Base, TimestampMixin): + """A record of a campaign sent to a specific prospect.""" + + __tablename__ = "campaign_sends" + + id = Column(Integer, primary_key=True, index=True) + template_id = Column(Integer, ForeignKey("campaign_templates.id", ondelete="SET NULL"), nullable=True) + prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True) + + channel = Column(Enum(CampaignChannel), nullable=False) + rendered_subject = Column(String(500), nullable=True) + rendered_body = Column(Text, nullable=True) + + status = Column(Enum(CampaignSendStatus), nullable=False, default=CampaignSendStatus.DRAFT) + sent_at = Column(DateTime, nullable=True) + sent_by_user_id = Column(Integer, nullable=True) diff --git a/app/modules/prospecting/models/interaction.py b/app/modules/prospecting/models/interaction.py new file mode 100644 index 00000000..5236b6ab --- /dev/null +++ b/app/modules/prospecting/models/interaction.py @@ -0,0 +1,54 @@ +# app/modules/prospecting/models/interaction.py +""" +Interaction tracking for prospect follow-ups. + +Logs all touchpoints: calls, emails, meetings, visits, notes. +""" + +import enum + +from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class InteractionType(str, enum.Enum): + NOTE = "note" + CALL = "call" + EMAIL_SENT = "email_sent" + EMAIL_RECEIVED = "email_received" + MEETING = "meeting" + VISIT = "visit" + SMS = "sms" + PROPOSAL_SENT = "proposal_sent" + + +class InteractionOutcome(str, enum.Enum): + POSITIVE = "positive" + NEUTRAL = "neutral" + NEGATIVE = "negative" + NO_ANSWER = "no_answer" + + +class ProspectInteraction(Base, TimestampMixin): + """A logged interaction with a prospect.""" + + __tablename__ = "prospect_interactions" + + id = Column(Integer, primary_key=True, index=True) + prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True) + + interaction_type = Column(Enum(InteractionType), nullable=False) + subject = Column(String(255), nullable=True) + notes = Column(Text, nullable=True) + outcome = Column(Enum(InteractionOutcome), nullable=True) + + next_action = Column(String(255), nullable=True) + next_action_date = Column(Date, nullable=True) + + created_by_user_id = Column(Integer, nullable=False) + + # Relationships + prospect = relationship("Prospect", back_populates="interactions") diff --git a/app/modules/prospecting/models/performance_profile.py b/app/modules/prospecting/models/performance_profile.py new file mode 100644 index 00000000..3cc7340e --- /dev/null +++ b/app/modules/prospecting/models/performance_profile.py @@ -0,0 +1,64 @@ +# app/modules/prospecting/models/performance_profile.py +""" +Performance profile for a prospect's website. + +Stores Lighthouse audit results including Core Web Vitals, +mobile-friendliness, and asset size analysis. +""" + +from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class ProspectPerformanceProfile(Base, TimestampMixin): + """Performance audit results from PageSpeed Insights / Lighthouse.""" + + __tablename__ = "prospect_performance_profiles" + + id = Column(Integer, primary_key=True, index=True) + prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True) + + # Lighthouse Scores (0-100) + performance_score = Column(Integer, nullable=True) + accessibility_score = Column(Integer, nullable=True) + best_practices_score = Column(Integer, nullable=True) + seo_score = Column(Integer, nullable=True) + + # Core Web Vitals + first_contentful_paint_ms = Column(Integer, nullable=True) + largest_contentful_paint_ms = Column(Integer, nullable=True) + total_blocking_time_ms = Column(Integer, nullable=True) + cumulative_layout_shift = Column(Float, nullable=True) + speed_index = Column(Integer, nullable=True) + time_to_interactive_ms = Column(Integer, nullable=True) + + # Mobile + is_mobile_friendly = Column(Boolean, nullable=True) + viewport_configured = Column(Boolean, nullable=True) + font_size_ok = Column(Boolean, nullable=True) + tap_targets_ok = Column(Boolean, nullable=True) + + # Asset Sizes (bytes) + total_bytes = Column(Integer, nullable=True) + html_bytes = Column(Integer, nullable=True) + css_bytes = Column(Integer, nullable=True) + js_bytes = Column(Integer, nullable=True) + image_bytes = Column(Integer, nullable=True) + font_bytes = Column(Integer, nullable=True) + + # Request Counts + total_requests = Column(Integer, nullable=True) + js_requests = Column(Integer, nullable=True) + css_requests = Column(Integer, nullable=True) + image_requests = Column(Integer, nullable=True) + + # Raw data + lighthouse_json = Column(Text, nullable=True) # JSON string + scan_strategy = Column(String(20), nullable=True) # mobile or desktop + scan_error = Column(Text, nullable=True) + + # Relationships + prospect = relationship("Prospect", back_populates="performance_profile") diff --git a/app/modules/prospecting/models/prospect.py b/app/modules/prospecting/models/prospect.py new file mode 100644 index 00000000..3a3065c6 --- /dev/null +++ b/app/modules/prospecting/models/prospect.py @@ -0,0 +1,82 @@ +# app/modules/prospecting/models/prospect.py +""" +Prospect model - core entity for lead discovery. + +Supports two channels: +- digital: discovered via domain scanning (.lu domains) +- offline: manually captured (street encounters, networking) +""" + +import enum + +from sqlalchemy import Boolean, Column, DateTime, Enum, Float, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class ProspectChannel(str, enum.Enum): + DIGITAL = "digital" + OFFLINE = "offline" + + +class ProspectStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + INACTIVE = "inactive" + PARKED = "parked" + ERROR = "error" + CONTACTED = "contacted" + CONVERTED = "converted" + + +class Prospect(Base, TimestampMixin): + """Represents a business prospect (potential client).""" + + __tablename__ = "prospects" + + id = Column(Integer, primary_key=True, index=True) + channel = Column(Enum(ProspectChannel), nullable=False, default=ProspectChannel.DIGITAL) + business_name = Column(String(255), nullable=True) + domain_name = Column(String(255), nullable=True, unique=True, index=True) + status = Column(Enum(ProspectStatus), nullable=False, default=ProspectStatus.PENDING) + source = Column(String(100), nullable=True) + + # Website status (digital channel) + has_website = Column(Boolean, nullable=True) + uses_https = Column(Boolean, nullable=True) + http_status_code = Column(Integer, nullable=True) + redirect_url = Column(Text, nullable=True) + + # Location (offline channel) + address = Column(String(500), nullable=True) + city = Column(String(100), nullable=True) + postal_code = Column(String(10), nullable=True) + country = Column(String(2), nullable=False, default="LU") + + # Notes and metadata + notes = Column(Text, nullable=True) + tags = Column(Text, nullable=True) # JSON string of tags + + # Capture info + captured_by_user_id = Column(Integer, nullable=True) + location_lat = Column(Float, nullable=True) + location_lng = Column(Float, nullable=True) + + # Scan timestamps + last_http_check_at = Column(DateTime, nullable=True) + last_tech_scan_at = Column(DateTime, nullable=True) + last_perf_scan_at = Column(DateTime, nullable=True) + last_contact_scrape_at = Column(DateTime, nullable=True) + + # Relationships + tech_profile = relationship("ProspectTechProfile", back_populates="prospect", uselist=False, cascade="all, delete-orphan") + performance_profile = relationship("ProspectPerformanceProfile", back_populates="prospect", uselist=False, cascade="all, delete-orphan") + score = relationship("ProspectScore", back_populates="prospect", uselist=False, cascade="all, delete-orphan") + contacts = relationship("ProspectContact", back_populates="prospect", cascade="all, delete-orphan") + interactions = relationship("ProspectInteraction", back_populates="prospect", cascade="all, delete-orphan") + + @property + def display_name(self) -> str: + return self.business_name or self.domain_name or f"Prospect #{self.id}" diff --git a/app/modules/prospecting/models/prospect_contact.py b/app/modules/prospecting/models/prospect_contact.py new file mode 100644 index 00000000..23453a13 --- /dev/null +++ b/app/modules/prospecting/models/prospect_contact.py @@ -0,0 +1,44 @@ +# app/modules/prospecting/models/prospect_contact.py +""" +Contact information for a prospect. + +Supports both auto-scraped (digital) and manually entered (offline) contacts. +""" + +import enum + +from sqlalchemy import Boolean, Column, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class ContactType(str, enum.Enum): + EMAIL = "email" + PHONE = "phone" + ADDRESS = "address" + SOCIAL = "social" + FORM = "form" + + +class ProspectContact(Base, TimestampMixin): + """Contact information associated with a prospect.""" + + __tablename__ = "prospect_contacts" + + id = Column(Integer, primary_key=True, index=True) + prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True) + + contact_type = Column(Enum(ContactType), nullable=False) + value = Column(String(500), nullable=False) + label = Column(String(100), nullable=True) # e.g., "info", "sales", "main" + source_url = Column(Text, nullable=True) # Page where contact was found + source_element = Column(String(100), nullable=True) # e.g., "mailto", "tel", "contact-form" + + is_validated = Column(Boolean, nullable=False, default=False) + validation_error = Column(Text, nullable=True) + is_primary = Column(Boolean, nullable=False, default=False) + + # Relationships + prospect = relationship("Prospect", back_populates="contacts") diff --git a/app/modules/prospecting/models/prospect_score.py b/app/modules/prospecting/models/prospect_score.py new file mode 100644 index 00000000..88191a72 --- /dev/null +++ b/app/modules/prospecting/models/prospect_score.py @@ -0,0 +1,46 @@ +# app/modules/prospecting/models/prospect_score.py +""" +Opportunity score for a prospect. + +Scoring algorithm: 0-100 total +- Technical Health: max 40pts +- Modernity: max 25pts +- Business Value: max 25pts +- Engagement: max 10pts +""" + +from sqlalchemy import Column, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class ProspectScore(Base, TimestampMixin): + """Opportunity score computed from prospect analysis.""" + + __tablename__ = "prospect_scores" + + id = Column(Integer, primary_key=True, index=True) + prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True) + + # Overall score + score = Column(Integer, nullable=False, default=0, index=True) + + # Component scores + technical_health_score = Column(Integer, nullable=False, default=0) # max 40 + modernity_score = Column(Integer, nullable=False, default=0) # max 25 + business_value_score = Column(Integer, nullable=False, default=0) # max 25 + engagement_score = Column(Integer, nullable=False, default=0) # max 10 + + # Detailed breakdown + reason_flags = Column(Text, nullable=True) # JSON array of flag strings + score_breakdown = Column(Text, nullable=True) # JSON dict of flag -> points + + # Lead tier classification + lead_tier = Column(String(20), nullable=True, index=True) # top_priority, quick_win, strategic, low_priority + + notes = Column(Text, nullable=True) + + # Relationships + prospect = relationship("Prospect", back_populates="score") diff --git a/app/modules/prospecting/models/scan_job.py b/app/modules/prospecting/models/scan_job.py new file mode 100644 index 00000000..85885bd2 --- /dev/null +++ b/app/modules/prospecting/models/scan_job.py @@ -0,0 +1,61 @@ +# app/modules/prospecting/models/scan_job.py +""" +Scan job tracking for batch enrichment operations. +""" + +import enum + +from sqlalchemy import Column, DateTime, Enum, Integer, String, Text + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class JobType(str, enum.Enum): + IMPORT = "import" + HTTP_CHECK = "http_check" + TECH_SCAN = "tech_scan" + PERFORMANCE_SCAN = "performance_scan" + CONTACT_SCRAPE = "contact_scrape" + SCORE_COMPUTE = "score_compute" + FULL_ENRICHMENT = "full_enrichment" + SECURITY_AUDIT = "security_audit" + + +class JobStatus(str, enum.Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ProspectScanJob(Base, TimestampMixin): + """Tracks batch scanning operations.""" + + __tablename__ = "prospect_scan_jobs" + + id = Column(Integer, primary_key=True, index=True) + job_type = Column(Enum(JobType), nullable=False) + status = Column(Enum(JobStatus), nullable=False, default=JobStatus.PENDING) + + total_items = Column(Integer, nullable=False, default=0) + processed_items = Column(Integer, nullable=False, default=0) + failed_items = Column(Integer, nullable=False, default=0) + skipped_items = Column(Integer, nullable=False, default=0) + + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + + config = Column(Text, nullable=True) # JSON string + result_summary = Column(Text, nullable=True) # JSON string + error_log = Column(Text, nullable=True) + source_file = Column(String(500), nullable=True) + + celery_task_id = Column(String(255), nullable=True) + + @property + def progress_percent(self) -> float: + if self.total_items == 0: + return 0.0 + return round(self.processed_items / self.total_items * 100, 1) diff --git a/app/modules/prospecting/models/tech_profile.py b/app/modules/prospecting/models/tech_profile.py new file mode 100644 index 00000000..72fa1c29 --- /dev/null +++ b/app/modules/prospecting/models/tech_profile.py @@ -0,0 +1,51 @@ +# app/modules/prospecting/models/tech_profile.py +""" +Technology profile for a prospect's website. + +Stores CMS, server, framework, analytics, and other +technology detection results from website scanning. +""" + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class ProspectTechProfile(Base, TimestampMixin): + """Technology profile detected from a prospect's website.""" + + __tablename__ = "prospect_tech_profiles" + + id = Column(Integer, primary_key=True, index=True) + prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True) + + # CMS Detection + cms = Column(String(100), nullable=True) + cms_version = Column(String(50), nullable=True) + + # Server + server = Column(String(100), nullable=True) + server_version = Column(String(50), nullable=True) + hosting_provider = Column(String(100), nullable=True) + cdn = Column(String(100), nullable=True) + + # SSL + has_valid_cert = Column(Boolean, nullable=True) + cert_issuer = Column(String(200), nullable=True) + cert_expires_at = Column(DateTime, nullable=True) + + # Frontend + js_framework = Column(String(100), nullable=True) + analytics = Column(String(200), nullable=True) + tag_manager = Column(String(100), nullable=True) + ecommerce_platform = Column(String(100), nullable=True) + + # Raw data + tech_stack_json = Column(Text, nullable=True) # JSON string + scan_source = Column(String(50), nullable=True) + scan_error = Column(Text, nullable=True) + + # Relationships + prospect = relationship("Prospect", back_populates="tech_profile") diff --git a/app/modules/prospecting/routes/__init__.py b/app/modules/prospecting/routes/__init__.py new file mode 100644 index 00000000..271eac42 --- /dev/null +++ b/app/modules/prospecting/routes/__init__.py @@ -0,0 +1 @@ +# app/modules/prospecting/routes/__init__.py diff --git a/app/modules/prospecting/routes/api/__init__.py b/app/modules/prospecting/routes/api/__init__.py new file mode 100644 index 00000000..000e28c7 --- /dev/null +++ b/app/modules/prospecting/routes/api/__init__.py @@ -0,0 +1 @@ +# app/modules/prospecting/routes/api/__init__.py diff --git a/app/modules/prospecting/routes/api/admin.py b/app/modules/prospecting/routes/api/admin.py new file mode 100644 index 00000000..f07b36ce --- /dev/null +++ b/app/modules/prospecting/routes/api/admin.py @@ -0,0 +1,30 @@ +# app/modules/prospecting/routes/api/admin.py +""" +Prospecting module admin API routes. + +Aggregates all admin prospecting routes: +- /prospects/* - Prospect CRUD and import +- /enrichment/* - Scanning pipeline +- /leads/* - Lead filtering and export +- /stats/* - Dashboard statistics +- /interactions/* - Interaction logging +- /campaigns/* - Campaign management +""" + +from fastapi import APIRouter + +from .admin_campaigns import router as admin_campaigns_router +from .admin_enrichment import router as admin_enrichment_router +from .admin_interactions import router as admin_interactions_router +from .admin_leads import router as admin_leads_router +from .admin_prospects import router as admin_prospects_router +from .admin_stats import router as admin_stats_router + +router = APIRouter() + +router.include_router(admin_prospects_router, tags=["prospecting-prospects"]) +router.include_router(admin_enrichment_router, tags=["prospecting-enrichment"]) +router.include_router(admin_leads_router, tags=["prospecting-leads"]) +router.include_router(admin_stats_router, tags=["prospecting-stats"]) +router.include_router(admin_interactions_router, tags=["prospecting-interactions"]) +router.include_router(admin_campaigns_router, tags=["prospecting-campaigns"]) diff --git a/app/modules/prospecting/routes/api/admin_campaigns.py b/app/modules/prospecting/routes/api/admin_campaigns.py new file mode 100644 index 00000000..be2f7b67 --- /dev/null +++ b/app/modules/prospecting/routes/api/admin_campaigns.py @@ -0,0 +1,116 @@ +# app/modules/prospecting/routes/api/admin_campaigns.py +""" +Admin API routes for campaign management. +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.prospecting.schemas.campaign import ( + CampaignPreviewRequest, + CampaignPreviewResponse, + CampaignSendCreate, + CampaignSendListResponse, + CampaignSendResponse, + CampaignTemplateCreate, + CampaignTemplateDeleteResponse, + CampaignTemplateResponse, + CampaignTemplateUpdate, +) +from app.modules.prospecting.services.campaign_service import campaign_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter(prefix="/campaigns") +logger = logging.getLogger(__name__) + + +@router.get("/templates", response_model=list[CampaignTemplateResponse]) +def list_templates( + lead_type: str | None = Query(None), + active_only: bool = Query(False), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """List campaign templates with optional filters.""" + templates = campaign_service.get_templates(db, lead_type=lead_type, active_only=active_only) + return [CampaignTemplateResponse.model_validate(t) for t in templates] + + +@router.post("/templates", response_model=CampaignTemplateResponse) +def create_template( + data: CampaignTemplateCreate, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Create a new campaign template.""" + template = campaign_service.create_template(db, data.model_dump()) + return CampaignTemplateResponse.model_validate(template) + + +@router.put("/templates/{template_id}", response_model=CampaignTemplateResponse) +def update_template( + data: CampaignTemplateUpdate, + template_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Update a campaign template.""" + template = campaign_service.update_template(db, template_id, data.model_dump(exclude_none=True)) + return CampaignTemplateResponse.model_validate(template) + + +@router.delete("/templates/{template_id}", response_model=CampaignTemplateDeleteResponse) +def delete_template( + template_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Delete a campaign template.""" + campaign_service.delete_template(db, template_id) + return CampaignTemplateDeleteResponse(message="Template deleted") + + +@router.post("/preview", response_model=CampaignPreviewResponse) +def preview_campaign( + data: CampaignPreviewRequest, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Preview a rendered campaign for a specific prospect.""" + result = campaign_service.render_campaign(db, data.template_id, data.prospect_id) + return CampaignPreviewResponse(**result) + + +@router.post("/send", response_model=list[CampaignSendResponse]) +def send_campaign( + data: CampaignSendCreate, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Send a campaign to one or more prospects.""" + sends = campaign_service.send_campaign( + db, + template_id=data.template_id, + prospect_ids=data.prospect_ids, + sent_by_user_id=current_admin.user_id, + ) + return [CampaignSendResponse.model_validate(s) for s in sends] + + +@router.get("/sends", response_model=CampaignSendListResponse) +def list_sends( + prospect_id: int | None = Query(None), + template_id: int | None = Query(None), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """List sent campaigns with optional filters.""" + sends = campaign_service.get_sends(db, prospect_id=prospect_id, template_id=template_id) + return CampaignSendListResponse( + items=[CampaignSendResponse.model_validate(s) for s in sends], + total=len(sends), + ) diff --git a/app/modules/prospecting/routes/api/admin_enrichment.py b/app/modules/prospecting/routes/api/admin_enrichment.py new file mode 100644 index 00000000..c4283104 --- /dev/null +++ b/app/modules/prospecting/routes/api/admin_enrichment.py @@ -0,0 +1,177 @@ +# app/modules/prospecting/routes/api/admin_enrichment.py +""" +Admin API routes for enrichment/scanning pipeline. +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.prospecting.schemas.enrichment import ( + ContactScrapeResponse, + FullEnrichmentResponse, + HttpCheckBatchItem, + HttpCheckBatchResponse, + HttpCheckResult, + ScanBatchResponse, + ScanSingleResponse, + ScoreComputeBatchResponse, +) +from app.modules.prospecting.services.enrichment_service import enrichment_service +from app.modules.prospecting.services.prospect_service import prospect_service +from app.modules.prospecting.services.scoring_service import scoring_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter(prefix="/enrichment") +logger = logging.getLogger(__name__) + + +@router.post("/http-check/{prospect_id}", response_model=HttpCheckResult) +def http_check_single( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Run HTTP connectivity check for a single prospect.""" + prospect = prospect_service.get_by_id(db, prospect_id) + result = enrichment_service.check_http(db, prospect) + return HttpCheckResult(**result) + + +@router.post("/http-check/batch", response_model=HttpCheckBatchResponse) +def http_check_batch( + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Run HTTP check for pending prospects.""" + prospects = prospect_service.get_pending_http_check(db, limit=limit) + results = [] + for prospect in prospects: + result = enrichment_service.check_http(db, prospect) + results.append(HttpCheckBatchItem(domain=prospect.domain_name, **result)) + return HttpCheckBatchResponse(processed=len(results), results=results) + + +@router.post("/tech-scan/{prospect_id}", response_model=ScanSingleResponse) +def tech_scan_single( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Run technology scan for a single prospect.""" + prospect = prospect_service.get_by_id(db, prospect_id) + profile = enrichment_service.scan_tech_stack(db, prospect) + return ScanSingleResponse(domain=prospect.domain_name, profile=profile is not None) + + +@router.post("/tech-scan/batch", response_model=ScanBatchResponse) +def tech_scan_batch( + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Run tech scan for pending prospects.""" + prospects = prospect_service.get_pending_tech_scan(db, limit=limit) + count = 0 + for prospect in prospects: + result = enrichment_service.scan_tech_stack(db, prospect) + if result: + count += 1 + return ScanBatchResponse(processed=len(prospects), successful=count) + + +@router.post("/performance/{prospect_id}", response_model=ScanSingleResponse) +def performance_scan_single( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Run PageSpeed audit for a single prospect.""" + prospect = prospect_service.get_by_id(db, prospect_id) + profile = enrichment_service.scan_performance(db, prospect) + return ScanSingleResponse(domain=prospect.domain_name, profile=profile is not None) + + +@router.post("/performance/batch", response_model=ScanBatchResponse) +def performance_scan_batch( + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Run performance scan for pending prospects.""" + prospects = prospect_service.get_pending_performance_scan(db, limit=limit) + count = 0 + for prospect in prospects: + result = enrichment_service.scan_performance(db, prospect) + if result: + count += 1 + return ScanBatchResponse(processed=len(prospects), successful=count) + + +@router.post("/contacts/{prospect_id}", response_model=ContactScrapeResponse) +def scrape_contacts_single( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Scrape contacts for a single prospect.""" + prospect = prospect_service.get_by_id(db, prospect_id) + contacts = enrichment_service.scrape_contacts(db, prospect) + return ContactScrapeResponse(domain=prospect.domain_name, contacts_found=len(contacts)) + + +@router.post("/full/{prospect_id}", response_model=FullEnrichmentResponse) +def full_enrichment( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Run full enrichment pipeline for a single prospect.""" + prospect = prospect_service.get_by_id(db, prospect_id) + + # Step 1: HTTP check + enrichment_service.check_http(db, prospect) + + # Step 2: Tech scan (if has website) + tech_profile = None + if prospect.has_website: + tech_profile = enrichment_service.scan_tech_stack(db, prospect) + + # Step 3: Performance scan (if has website) + perf_profile = None + if prospect.has_website: + perf_profile = enrichment_service.scan_performance(db, prospect) + + # Step 4: Contact scrape (if has website) + contacts = [] + if prospect.has_website: + contacts = enrichment_service.scrape_contacts(db, prospect) + + # Step 5: Compute score + db.refresh(prospect) + score = scoring_service.compute_score(db, prospect) + + return FullEnrichmentResponse( + domain=prospect.domain_name, + has_website=prospect.has_website, + tech_scanned=tech_profile is not None, + perf_scanned=perf_profile is not None, + contacts_found=len(contacts), + score=score.score, + lead_tier=score.lead_tier, + ) + + +@router.post("/score-compute/batch", response_model=ScoreComputeBatchResponse) +def compute_scores_batch( + limit: int = Query(500, ge=1, le=5000), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Compute or recompute scores for all prospects.""" + count = scoring_service.compute_all(db, limit=limit) + return ScoreComputeBatchResponse(scored=count) diff --git a/app/modules/prospecting/routes/api/admin_interactions.py b/app/modules/prospecting/routes/api/admin_interactions.py new file mode 100644 index 00000000..a251b3cd --- /dev/null +++ b/app/modules/prospecting/routes/api/admin_interactions.py @@ -0,0 +1,65 @@ +# app/modules/prospecting/routes/api/admin_interactions.py +""" +Admin API routes for interaction logging and follow-ups. +""" + +import logging +from datetime import date + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.prospecting.schemas.interaction import ( + InteractionCreate, + InteractionListResponse, + InteractionResponse, +) +from app.modules.prospecting.services.interaction_service import interaction_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/prospects/{prospect_id}/interactions", response_model=InteractionListResponse) +def get_prospect_interactions( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get all interactions for a prospect.""" + interactions = interaction_service.get_for_prospect(db, prospect_id) + return InteractionListResponse( + items=[InteractionResponse.model_validate(i) for i in interactions], + total=len(interactions), + ) + + +@router.post("/prospects/{prospect_id}/interactions", response_model=InteractionResponse) +def create_interaction( + data: InteractionCreate, + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Log a new interaction for a prospect.""" + interaction = interaction_service.create( + db, + prospect_id=prospect_id, + user_id=current_admin.user_id, + data=data.model_dump(exclude_none=True), + ) + return InteractionResponse.model_validate(interaction) + + +@router.get("/interactions/upcoming") +def get_upcoming_actions( + before: date | None = Query(None), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get interactions with upcoming follow-up actions.""" + interactions = interaction_service.get_upcoming_actions(db, before_date=before) + return [InteractionResponse.model_validate(i) for i in interactions] diff --git a/app/modules/prospecting/routes/api/admin_leads.py b/app/modules/prospecting/routes/api/admin_leads.py new file mode 100644 index 00000000..e00067af --- /dev/null +++ b/app/modules/prospecting/routes/api/admin_leads.py @@ -0,0 +1,99 @@ +# app/modules/prospecting/routes/api/admin_leads.py +""" +Admin API routes for lead filtering and export. +""" + +import logging +from math import ceil + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.prospecting.services.lead_service import lead_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter(prefix="/leads") +logger = logging.getLogger(__name__) + + +@router.get("") +def list_leads( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + min_score: int = Query(0, ge=0, le=100), + max_score: int = Query(100, ge=0, le=100), + lead_tier: str | None = Query(None), + channel: str | None = Query(None), + has_email: bool | None = Query(None), + has_phone: bool | None = Query(None), + reason_flag: str | None = Query(None), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get filtered leads with scores.""" + leads, total = lead_service.get_leads( + db, + page=page, + per_page=per_page, + min_score=min_score, + max_score=max_score, + lead_tier=lead_tier, + channel=channel, + has_email=has_email, + has_phone=has_phone, + reason_flag=reason_flag, + ) + return { + "items": leads, + "total": total, + "page": page, + "per_page": per_page, + "pages": ceil(total / per_page) if per_page else 0, + } + + +@router.get("/top-priority") +def top_priority_leads( + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get top priority leads (score >= 70).""" + return lead_service.get_top_priority(db, limit=limit) + + +@router.get("/quick-wins") +def quick_win_leads( + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get quick win leads (score 50-69).""" + return lead_service.get_quick_wins(db, limit=limit) + + +@router.get("/export/csv") +def export_leads_csv( + min_score: int = Query(0, ge=0, le=100), + lead_tier: str | None = Query(None), + channel: str | None = Query(None), + limit: int = Query(1000, ge=1, le=10000), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Export filtered leads as CSV download.""" + csv_content = lead_service.export_csv( + db, + min_score=min_score, + lead_tier=lead_tier, + channel=channel, + limit=limit, + ) + return StreamingResponse( + iter([csv_content]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=leads_export.csv"}, + ) diff --git a/app/modules/prospecting/routes/api/admin_prospects.py b/app/modules/prospecting/routes/api/admin_prospects.py new file mode 100644 index 00000000..df358457 --- /dev/null +++ b/app/modules/prospecting/routes/api/admin_prospects.py @@ -0,0 +1,203 @@ +# app/modules/prospecting/routes/api/admin_prospects.py +""" +Admin API routes for prospect management. +""" + +import logging +from math import ceil + +from fastapi import APIRouter, Depends, Path, Query, UploadFile +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.prospecting.schemas.prospect import ( + ProspectCreate, + ProspectDeleteResponse, + ProspectDetailResponse, + ProspectImportResponse, + ProspectListResponse, + ProspectResponse, + ProspectUpdate, +) +from app.modules.prospecting.services.prospect_service import prospect_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter(prefix="/prospects") +logger = logging.getLogger(__name__) + + +@router.get("", response_model=ProspectListResponse) +def list_prospects( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + search: str | None = Query(None), + channel: str | None = Query(None), + status: str | None = Query(None), + tier: str | None = Query(None), + city: str | None = Query(None), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """List prospects with filters and pagination.""" + prospects, total = prospect_service.get_all( + db, + page=page, + per_page=per_page, + search=search, + channel=channel, + status=status, + tier=tier, + city=city, + ) + return ProspectListResponse( + items=[_to_response(p) for p in prospects], + total=total, + page=page, + per_page=per_page, + pages=ceil(total / per_page) if per_page else 0, + ) + + +@router.get("/{prospect_id}", response_model=ProspectDetailResponse) +def get_prospect( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get full prospect detail with all related data.""" + prospect = prospect_service.get_by_id(db, prospect_id) + return prospect + + +@router.post("", response_model=ProspectResponse) +def create_prospect( + data: ProspectCreate, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Create a new prospect (digital or offline).""" + prospect = prospect_service.create( + db, + data.model_dump(exclude_none=True), + captured_by_user_id=current_admin.user_id, + ) + return _to_response(prospect) + + +@router.put("/{prospect_id}", response_model=ProspectResponse) +def update_prospect( + data: ProspectUpdate, + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Update a prospect.""" + prospect = prospect_service.update(db, prospect_id, data.model_dump(exclude_none=True)) + return _to_response(prospect) + + +@router.delete("/{prospect_id}", response_model=ProspectDeleteResponse) +def delete_prospect( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Delete a prospect.""" + prospect_service.delete(db, prospect_id) + return ProspectDeleteResponse(message="Prospect deleted") + + +@router.post("/import", response_model=ProspectImportResponse) +async def import_domains( + file: UploadFile, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Import domains from a CSV file.""" + content = await file.read() + lines = content.decode("utf-8").strip().split("\n") + # Skip header if present + domains = [] + for line in lines: + domain = line.strip().strip(",").strip('"') + if domain and "." in domain and not domain.startswith("#"): + domains.append(domain) + + created, skipped = prospect_service.create_bulk(db, domains, source="csv_import") + return ProspectImportResponse(created=created, skipped=skipped, total=len(domains)) + + +def _to_response(prospect) -> ProspectResponse: + """Convert a prospect model to response schema.""" + import json + + contacts = prospect.contacts or [] + primary_email = next((c.value for c in contacts if c.contact_type == "email"), None) + primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None) + + tags = None + if prospect.tags: + try: + tags = json.loads(prospect.tags) + except (json.JSONDecodeError, TypeError): + tags = None + + score_resp = None + if prospect.score: + from app.modules.prospecting.schemas.score import ProspectScoreResponse + + reason_flags = [] + if prospect.score.reason_flags: + try: + reason_flags = json.loads(prospect.score.reason_flags) + except (json.JSONDecodeError, TypeError): + pass + + score_breakdown = None + if prospect.score.score_breakdown: + try: + score_breakdown = json.loads(prospect.score.score_breakdown) + except (json.JSONDecodeError, TypeError): + pass + + score_resp = ProspectScoreResponse( + id=prospect.score.id, + prospect_id=prospect.score.prospect_id, + score=prospect.score.score, + technical_health_score=prospect.score.technical_health_score, + modernity_score=prospect.score.modernity_score, + business_value_score=prospect.score.business_value_score, + engagement_score=prospect.score.engagement_score, + reason_flags=reason_flags, + score_breakdown=score_breakdown, + lead_tier=prospect.score.lead_tier, + notes=prospect.score.notes, + created_at=prospect.score.created_at, + updated_at=prospect.score.updated_at, + ) + + return ProspectResponse( + id=prospect.id, + channel=prospect.channel.value if prospect.channel else "digital", + business_name=prospect.business_name, + domain_name=prospect.domain_name, + status=prospect.status.value if prospect.status else "pending", + source=prospect.source, + has_website=prospect.has_website, + uses_https=prospect.uses_https, + http_status_code=prospect.http_status_code, + address=prospect.address, + city=prospect.city, + postal_code=prospect.postal_code, + country=prospect.country, + notes=prospect.notes, + tags=tags, + location_lat=prospect.location_lat, + location_lng=prospect.location_lng, + created_at=prospect.created_at, + updated_at=prospect.updated_at, + score=score_resp, + primary_email=primary_email, + primary_phone=primary_phone, + ) diff --git a/app/modules/prospecting/routes/api/admin_stats.py b/app/modules/prospecting/routes/api/admin_stats.py new file mode 100644 index 00000000..58c31e25 --- /dev/null +++ b/app/modules/prospecting/routes/api/admin_stats.py @@ -0,0 +1,50 @@ +# app/modules/prospecting/routes/api/admin_stats.py +""" +Admin API routes for dashboard statistics. +""" + +import logging +from math import ceil + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.prospecting.schemas.scan_job import ( + ScanJobListResponse, + ScanJobResponse, +) +from app.modules.prospecting.services.stats_service import stats_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter(prefix="/stats") +logger = logging.getLogger(__name__) + + +@router.get("") +def get_overview_stats( + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get overview statistics for the dashboard.""" + return stats_service.get_overview(db) + + +@router.get("/jobs", response_model=ScanJobListResponse) +def list_scan_jobs( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + status: str | None = Query(None), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get paginated scan jobs.""" + jobs, total = stats_service.get_scan_jobs(db, page=page, per_page=per_page, status=status) + return ScanJobListResponse( + items=[ScanJobResponse.model_validate(j) for j in jobs], + total=total, + page=page, + per_page=per_page, + pages=ceil(total / per_page) if per_page else 0, + ) diff --git a/app/modules/prospecting/routes/pages/__init__.py b/app/modules/prospecting/routes/pages/__init__.py new file mode 100644 index 00000000..b80b2979 --- /dev/null +++ b/app/modules/prospecting/routes/pages/__init__.py @@ -0,0 +1 @@ +# app/modules/prospecting/routes/pages/__init__.py diff --git a/app/modules/prospecting/routes/pages/admin.py b/app/modules/prospecting/routes/pages/admin.py new file mode 100644 index 00000000..4499980b --- /dev/null +++ b/app/modules/prospecting/routes/pages/admin.py @@ -0,0 +1,123 @@ +# app/modules/prospecting/routes/pages/admin.py +""" +Prospecting Admin Page Routes (HTML rendering). + +Admin pages for lead discovery and campaign management: +- Dashboard - Overview with stats and charts +- Prospects - Prospect list with filters +- Prospect Detail - Single prospect view with tabs +- Leads - Scored lead list +- Quick Capture - Mobile-friendly lead capture form +- Scan Jobs - Scan job monitoring +- Campaigns - Campaign template management +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_menu_access +from app.modules.core.utils.page_context import get_admin_context +from app.modules.enums import FrontendType +from app.modules.tenancy.models import User +from app.templates_config import templates + +router = APIRouter() + + +@router.get("/prospecting", response_class=HTMLResponse, include_in_schema=False) +async def admin_prospecting_dashboard( + request: Request, + current_user: User = Depends(require_menu_access("prospecting-dashboard", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render prospecting dashboard page.""" + return templates.TemplateResponse( + "prospecting/admin/dashboard.html", + get_admin_context(request, db, current_user), + ) + + +@router.get("/prospecting/prospects", response_class=HTMLResponse, include_in_schema=False) +async def admin_prospects_list( + request: Request, + current_user: User = Depends(require_menu_access("prospects", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render prospects list page.""" + return templates.TemplateResponse( + "prospecting/admin/prospects.html", + get_admin_context(request, db, current_user), + ) + + +@router.get( + "/prospecting/prospects/{prospect_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_prospect_detail( + request: Request, + prospect_id: int = Path(..., description="Prospect ID"), + current_user: User = Depends(require_menu_access("prospects", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render single prospect detail page.""" + context = get_admin_context(request, db, current_user) + context["prospect_id"] = prospect_id + return templates.TemplateResponse( + "prospecting/admin/prospect-detail.html", + context, + ) + + +@router.get("/prospecting/leads", response_class=HTMLResponse, include_in_schema=False) +async def admin_leads_list( + request: Request, + current_user: User = Depends(require_menu_access("leads", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render leads list page.""" + return templates.TemplateResponse( + "prospecting/admin/leads.html", + get_admin_context(request, db, current_user), + ) + + +@router.get("/prospecting/capture", response_class=HTMLResponse, include_in_schema=False) +async def admin_quick_capture( + request: Request, + current_user: User = Depends(require_menu_access("capture", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render mobile-friendly quick capture form.""" + return templates.TemplateResponse( + "prospecting/admin/capture.html", + get_admin_context(request, db, current_user), + ) + + +@router.get("/prospecting/scan-jobs", response_class=HTMLResponse, include_in_schema=False) +async def admin_scan_jobs( + request: Request, + current_user: User = Depends(require_menu_access("scan-jobs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render scan jobs monitoring page.""" + return templates.TemplateResponse( + "prospecting/admin/scan-jobs.html", + get_admin_context(request, db, current_user), + ) + + +@router.get("/prospecting/campaigns", response_class=HTMLResponse, include_in_schema=False) +async def admin_campaigns( + request: Request, + current_user: User = Depends(require_menu_access("campaigns", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render campaign management page.""" + return templates.TemplateResponse( + "prospecting/admin/campaigns.html", + get_admin_context(request, db, current_user), + ) diff --git a/app/modules/prospecting/schemas/__init__.py b/app/modules/prospecting/schemas/__init__.py new file mode 100644 index 00000000..e9b691af --- /dev/null +++ b/app/modules/prospecting/schemas/__init__.py @@ -0,0 +1,66 @@ +# app/modules/prospecting/schemas/__init__.py +from app.modules.prospecting.schemas.campaign import ( + CampaignPreviewRequest, + CampaignPreviewResponse, + CampaignSendCreate, + CampaignSendListResponse, + CampaignSendResponse, + CampaignTemplateCreate, + CampaignTemplateResponse, + CampaignTemplateUpdate, +) +from app.modules.prospecting.schemas.contact import ( + ProspectContactCreate, + ProspectContactResponse, +) +from app.modules.prospecting.schemas.interaction import ( + InteractionCreate, + InteractionListResponse, + InteractionResponse, +) +from app.modules.prospecting.schemas.performance_profile import ( + PerformanceProfileResponse, +) +from app.modules.prospecting.schemas.prospect import ( + ProspectCreate, + ProspectDeleteResponse, + ProspectDetailResponse, + ProspectImportResponse, + ProspectListResponse, + ProspectResponse, + ProspectUpdate, +) +from app.modules.prospecting.schemas.scan_job import ( + ScanJobListResponse, + ScanJobResponse, +) +from app.modules.prospecting.schemas.score import ProspectScoreResponse +from app.modules.prospecting.schemas.tech_profile import TechProfileResponse + +__all__ = [ + "ProspectCreate", + "ProspectUpdate", + "ProspectResponse", + "ProspectDetailResponse", + "ProspectListResponse", + "ProspectDeleteResponse", + "ProspectImportResponse", + "TechProfileResponse", + "PerformanceProfileResponse", + "ProspectScoreResponse", + "ProspectContactCreate", + "ProspectContactResponse", + "ScanJobResponse", + "ScanJobListResponse", + "InteractionCreate", + "InteractionResponse", + "InteractionListResponse", + "CampaignTemplateCreate", + "CampaignTemplateUpdate", + "CampaignTemplateResponse", + "CampaignSendCreate", + "CampaignPreviewRequest", + "CampaignPreviewResponse", + "CampaignSendResponse", + "CampaignSendListResponse", +] diff --git a/app/modules/prospecting/schemas/campaign.py b/app/modules/prospecting/schemas/campaign.py new file mode 100644 index 00000000..6f1f9cd9 --- /dev/null +++ b/app/modules/prospecting/schemas/campaign.py @@ -0,0 +1,103 @@ +# app/modules/prospecting/schemas/campaign.py +"""Pydantic schemas for campaign management.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CampaignTemplateCreate(BaseModel): + """Schema for creating a campaign template.""" + + name: str = Field(..., max_length=255) + lead_type: str = Field( + ..., + pattern="^(no_website|bad_website|gmail_only|security_issues|performance_issues|outdated_cms|general)$", + ) + channel: str = Field("email", pattern="^(email|letter|phone_script)$") + language: str = Field("fr", max_length=5) + subject_template: str | None = Field(None, max_length=500) + body_template: str = Field(...) + is_active: bool = True + + +class CampaignTemplateUpdate(BaseModel): + """Schema for updating a campaign template.""" + + name: str | None = Field(None, max_length=255) + lead_type: str | None = None + channel: str | None = None + language: str | None = Field(None, max_length=5) + subject_template: str | None = None + body_template: str | None = None + is_active: bool | None = None + + +class CampaignTemplateResponse(BaseModel): + """Schema for campaign template response.""" + + id: int + name: str + lead_type: str + channel: str + language: str + subject_template: str | None = None + body_template: str + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class CampaignSendCreate(BaseModel): + """Schema for sending a campaign.""" + + template_id: int + prospect_ids: list[int] = Field(..., min_length=1) + + +class CampaignPreviewRequest(BaseModel): + """Schema for previewing a rendered campaign.""" + + template_id: int + prospect_id: int + + +class CampaignPreviewResponse(BaseModel): + """Schema for campaign preview response.""" + + subject: str | None = None + body: str + + +class CampaignSendResponse(BaseModel): + """Schema for campaign send record response.""" + + id: int + template_id: int | None = None + prospect_id: int + channel: str + rendered_subject: str | None = None + rendered_body: str | None = None + status: str + sent_at: datetime | None = None + sent_by_user_id: int | None = None + created_at: datetime + + class Config: + from_attributes = True + + +class CampaignSendListResponse(BaseModel): + """List of campaign sends.""" + + items: list[CampaignSendResponse] + total: int + + +class CampaignTemplateDeleteResponse(BaseModel): + """Response for template deletion.""" + + message: str diff --git a/app/modules/prospecting/schemas/contact.py b/app/modules/prospecting/schemas/contact.py new file mode 100644 index 00000000..0d6c1f8e --- /dev/null +++ b/app/modules/prospecting/schemas/contact.py @@ -0,0 +1,34 @@ +# app/modules/prospecting/schemas/contact.py +"""Pydantic schemas for prospect contacts.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ProspectContactCreate(BaseModel): + """Schema for creating a contact.""" + + contact_type: str = Field(..., pattern="^(email|phone|address|social|form)$") + value: str = Field(..., max_length=500) + label: str | None = Field(None, max_length=100) + is_primary: bool = False + + +class ProspectContactResponse(BaseModel): + """Schema for contact response.""" + + id: int + prospect_id: int + contact_type: str + value: str + label: str | None = None + source_url: str | None = None + source_element: str | None = None + is_validated: bool = False + is_primary: bool = False + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/modules/prospecting/schemas/enrichment.py b/app/modules/prospecting/schemas/enrichment.py new file mode 100644 index 00000000..85719604 --- /dev/null +++ b/app/modules/prospecting/schemas/enrichment.py @@ -0,0 +1,69 @@ +# app/modules/prospecting/schemas/enrichment.py +"""Pydantic response schemas for enrichment/scanning endpoints.""" + +from pydantic import BaseModel + + +class HttpCheckResult(BaseModel): + """Response from a single HTTP check.""" + + has_website: bool + uses_https: bool | None = None + http_status_code: int | None = None + redirect_url: str | None = None + + +class HttpCheckBatchItem(BaseModel): + """Single item in a batch HTTP check response.""" + + domain: str + has_website: bool + uses_https: bool | None = None + http_status_code: int | None = None + redirect_url: str | None = None + + +class HttpCheckBatchResponse(BaseModel): + """Response from batch HTTP check.""" + + processed: int + results: list[HttpCheckBatchItem] + + +class ScanSingleResponse(BaseModel): + """Response from a single scan (tech or performance).""" + + domain: str + profile: bool + + +class ScanBatchResponse(BaseModel): + """Response from a batch scan.""" + + processed: int + successful: int + + +class ContactScrapeResponse(BaseModel): + """Response from a single contact scrape.""" + + domain: str + contacts_found: int + + +class FullEnrichmentResponse(BaseModel): + """Response from full enrichment pipeline.""" + + domain: str + has_website: bool | None = None + tech_scanned: bool + perf_scanned: bool + contacts_found: int + score: int + lead_tier: str + + +class ScoreComputeBatchResponse(BaseModel): + """Response from batch score computation.""" + + scored: int diff --git a/app/modules/prospecting/schemas/interaction.py b/app/modules/prospecting/schemas/interaction.py new file mode 100644 index 00000000..bf92b9e9 --- /dev/null +++ b/app/modules/prospecting/schemas/interaction.py @@ -0,0 +1,44 @@ +# app/modules/prospecting/schemas/interaction.py +"""Pydantic schemas for prospect interactions.""" + +from datetime import date, datetime + +from pydantic import BaseModel, Field + + +class InteractionCreate(BaseModel): + """Schema for creating an interaction.""" + + interaction_type: str = Field( + ..., pattern="^(note|call|email_sent|email_received|meeting|visit|sms|proposal_sent)$" + ) + subject: str | None = Field(None, max_length=255) + notes: str | None = None + outcome: str | None = Field(None, pattern="^(positive|neutral|negative|no_answer)$") + next_action: str | None = Field(None, max_length=255) + next_action_date: date | None = None + + +class InteractionResponse(BaseModel): + """Schema for interaction response.""" + + id: int + prospect_id: int + interaction_type: str + subject: str | None = None + notes: str | None = None + outcome: str | None = None + next_action: str | None = None + next_action_date: date | None = None + created_by_user_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class InteractionListResponse(BaseModel): + """List of interactions.""" + + items: list[InteractionResponse] + total: int diff --git a/app/modules/prospecting/schemas/performance_profile.py b/app/modules/prospecting/schemas/performance_profile.py new file mode 100644 index 00000000..4b4b5be2 --- /dev/null +++ b/app/modules/prospecting/schemas/performance_profile.py @@ -0,0 +1,33 @@ +# app/modules/prospecting/schemas/performance_profile.py +"""Pydantic schemas for performance profile.""" + +from datetime import datetime + +from pydantic import BaseModel + + +class PerformanceProfileResponse(BaseModel): + """Schema for performance profile response.""" + + id: int + prospect_id: int + performance_score: int | None = None + accessibility_score: int | None = None + best_practices_score: int | None = None + seo_score: int | None = None + first_contentful_paint_ms: int | None = None + largest_contentful_paint_ms: int | None = None + total_blocking_time_ms: int | None = None + cumulative_layout_shift: float | None = None + speed_index: int | None = None + time_to_interactive_ms: int | None = None + is_mobile_friendly: bool | None = None + viewport_configured: bool | None = None + total_bytes: int | None = None + scan_strategy: str | None = None + scan_error: str | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/modules/prospecting/schemas/prospect.py b/app/modules/prospecting/schemas/prospect.py new file mode 100644 index 00000000..96b72984 --- /dev/null +++ b/app/modules/prospecting/schemas/prospect.py @@ -0,0 +1,122 @@ +# app/modules/prospecting/schemas/prospect.py +"""Pydantic schemas for prospect management.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ProspectCreate(BaseModel): + """Schema for creating a prospect.""" + + channel: str = Field("digital", pattern="^(digital|offline)$") + business_name: str | None = Field(None, max_length=255) + domain_name: str | None = Field(None, max_length=255) + source: str | None = Field(None, max_length=100) + address: str | None = Field(None, max_length=500) + city: str | None = Field(None, max_length=100) + postal_code: str | None = Field(None, max_length=10) + country: str = Field("LU", max_length=2) + notes: str | None = None + tags: list[str] | None = None + location_lat: float | None = None + location_lng: float | None = None + contacts: list["ProspectContactCreate"] | None = None + + +class ProspectUpdate(BaseModel): + """Schema for updating a prospect.""" + + business_name: str | None = Field(None, max_length=255) + status: str | None = None + source: str | None = Field(None, max_length=100) + address: str | None = Field(None, max_length=500) + city: str | None = Field(None, max_length=100) + postal_code: str | None = Field(None, max_length=10) + notes: str | None = None + tags: list[str] | None = None + + +class ProspectResponse(BaseModel): + """Schema for prospect response.""" + + id: int + channel: str + business_name: str | None = None + domain_name: str | None = None + status: str + source: str | None = None + has_website: bool | None = None + uses_https: bool | None = None + http_status_code: int | None = None + address: str | None = None + city: str | None = None + postal_code: str | None = None + country: str = "LU" + notes: str | None = None + tags: list[str] | None = None + location_lat: float | None = None + location_lng: float | None = None + created_at: datetime + updated_at: datetime + + # Nested (optional, included in detail view) + score: "ProspectScoreResponse | None" = None + primary_email: str | None = None + primary_phone: str | None = None + + class Config: + from_attributes = True + + +class ProspectDetailResponse(ProspectResponse): + """Full prospect detail with all related data.""" + + tech_profile: "TechProfileResponse | None" = None + performance_profile: "PerformanceProfileResponse | None" = None + contacts: list["ProspectContactResponse"] = [] + + class Config: + from_attributes = True + + +class ProspectListResponse(BaseModel): + """Paginated prospect list response.""" + + items: list[ProspectResponse] + total: int + page: int + per_page: int + pages: int + + +class ProspectDeleteResponse(BaseModel): + """Response for prospect deletion.""" + + message: str + + +class ProspectImportResponse(BaseModel): + """Response for domain import.""" + + created: int + skipped: int + total: int + + +# Forward references resolved at module level +from app.modules.prospecting.schemas.contact import ( # noqa: E402 + ProspectContactCreate, + ProspectContactResponse, +) +from app.modules.prospecting.schemas.performance_profile import ( + PerformanceProfileResponse, # noqa: E402 +) +from app.modules.prospecting.schemas.score import ProspectScoreResponse # noqa: E402 +from app.modules.prospecting.schemas.tech_profile import ( + TechProfileResponse, # noqa: E402 +) + +ProspectCreate.model_rebuild() +ProspectResponse.model_rebuild() +ProspectDetailResponse.model_rebuild() diff --git a/app/modules/prospecting/schemas/scan_job.py b/app/modules/prospecting/schemas/scan_job.py new file mode 100644 index 00000000..b7dbf715 --- /dev/null +++ b/app/modules/prospecting/schemas/scan_job.py @@ -0,0 +1,38 @@ +# app/modules/prospecting/schemas/scan_job.py +"""Pydantic schemas for scan jobs.""" + +from datetime import datetime + +from pydantic import BaseModel + + +class ScanJobResponse(BaseModel): + """Schema for scan job response.""" + + id: int + job_type: str + status: str + total_items: int + processed_items: int + failed_items: int + skipped_items: int + progress_percent: float + started_at: datetime | None = None + completed_at: datetime | None = None + error_log: str | None = None + celery_task_id: str | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ScanJobListResponse(BaseModel): + """Paginated scan job list.""" + + items: list[ScanJobResponse] + total: int + page: int + per_page: int + pages: int diff --git a/app/modules/prospecting/schemas/score.py b/app/modules/prospecting/schemas/score.py new file mode 100644 index 00000000..a184206f --- /dev/null +++ b/app/modules/prospecting/schemas/score.py @@ -0,0 +1,27 @@ +# app/modules/prospecting/schemas/score.py +"""Pydantic schemas for opportunity scoring.""" + +from datetime import datetime + +from pydantic import BaseModel + + +class ProspectScoreResponse(BaseModel): + """Schema for prospect score response.""" + + id: int + prospect_id: int + score: int + technical_health_score: int + modernity_score: int + business_value_score: int + engagement_score: int + reason_flags: list[str] = [] + score_breakdown: dict[str, int] | None = None + lead_tier: str | None = None + notes: str | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/modules/prospecting/schemas/tech_profile.py b/app/modules/prospecting/schemas/tech_profile.py new file mode 100644 index 00000000..8084c2d3 --- /dev/null +++ b/app/modules/prospecting/schemas/tech_profile.py @@ -0,0 +1,33 @@ +# app/modules/prospecting/schemas/tech_profile.py +"""Pydantic schemas for technology profile.""" + +from datetime import datetime + +from pydantic import BaseModel + + +class TechProfileResponse(BaseModel): + """Schema for technology profile response.""" + + id: int + prospect_id: int + cms: str | None = None + cms_version: str | None = None + server: str | None = None + server_version: str | None = None + hosting_provider: str | None = None + cdn: str | None = None + has_valid_cert: bool | None = None + cert_issuer: str | None = None + cert_expires_at: datetime | None = None + js_framework: str | None = None + analytics: str | None = None + tag_manager: str | None = None + ecommerce_platform: str | None = None + scan_source: str | None = None + scan_error: str | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/modules/prospecting/services/__init__.py b/app/modules/prospecting/services/__init__.py new file mode 100644 index 00000000..4b084c0c --- /dev/null +++ b/app/modules/prospecting/services/__init__.py @@ -0,0 +1 @@ +# app/modules/prospecting/services/__init__.py diff --git a/app/modules/prospecting/services/campaign_service.py b/app/modules/prospecting/services/campaign_service.py new file mode 100644 index 00000000..2ac74ac8 --- /dev/null +++ b/app/modules/prospecting/services/campaign_service.py @@ -0,0 +1,190 @@ +# app/modules/prospecting/services/campaign_service.py +""" +Campaign management service. + +Handles campaign template CRUD, rendering with prospect data, +and send tracking. +""" + +import json +import logging +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from app.modules.prospecting.exceptions import ( + CampaignRenderException, + CampaignTemplateNotFoundException, +) +from app.modules.prospecting.models import ( + CampaignSend, + CampaignSendStatus, + CampaignTemplate, + Prospect, +) +from app.modules.prospecting.services.prospect_service import prospect_service + +logger = logging.getLogger(__name__) + + +class CampaignService: + """Service for campaign template management and sending.""" + + # --- Template CRUD --- + + def get_templates( + self, + db: Session, + *, + lead_type: str | None = None, + active_only: bool = False, + ) -> list[CampaignTemplate]: + query = db.query(CampaignTemplate) + if lead_type: + query = query.filter(CampaignTemplate.lead_type == lead_type) + if active_only: + query = query.filter(CampaignTemplate.is_active.is_(True)) + return query.order_by(CampaignTemplate.lead_type, CampaignTemplate.name).all() + + def get_template_by_id(self, db: Session, template_id: int) -> CampaignTemplate: + template = db.query(CampaignTemplate).filter(CampaignTemplate.id == template_id).first() + if not template: + raise CampaignTemplateNotFoundException(str(template_id)) + return template + + def create_template(self, db: Session, data: dict) -> CampaignTemplate: + template = CampaignTemplate( + name=data["name"], + lead_type=data["lead_type"], + channel=data.get("channel", "email"), + language=data.get("language", "fr"), + subject_template=data.get("subject_template"), + body_template=data["body_template"], + is_active=data.get("is_active", True), + ) + db.add(template) + db.commit() + db.refresh(template) + return template + + def update_template(self, db: Session, template_id: int, data: dict) -> CampaignTemplate: + template = self.get_template_by_id(db, template_id) + for field in ["name", "lead_type", "channel", "language", "subject_template", "body_template", "is_active"]: + if field in data and data[field] is not None: + setattr(template, field, data[field]) + db.commit() + db.refresh(template) + return template + + def delete_template(self, db: Session, template_id: int) -> bool: + template = self.get_template_by_id(db, template_id) + db.delete(template) + db.commit() + return True + + # --- Rendering --- + + def render_campaign(self, db: Session, template_id: int, prospect_id: int) -> dict: + """Render a campaign template with prospect data.""" + template = self.get_template_by_id(db, template_id) + prospect = prospect_service.get_by_id(db, prospect_id) + + placeholders = self._build_placeholders(prospect) + + try: + rendered_subject = None + if template.subject_template: + rendered_subject = template.subject_template.format(**placeholders) + rendered_body = template.body_template.format(**placeholders) + except KeyError as e: + raise CampaignRenderException(template_id, f"Missing placeholder: {e}") + + return { + "subject": rendered_subject, + "body": rendered_body, + } + + # --- Sending --- + + def send_campaign( + self, + db: Session, + template_id: int, + prospect_ids: list[int], + sent_by_user_id: int, + ) -> list[CampaignSend]: + """Create campaign send records for prospects.""" + template = self.get_template_by_id(db, template_id) + sends = [] + + for pid in prospect_ids: + prospect = prospect_service.get_by_id(db, pid) + placeholders = self._build_placeholders(prospect) + + try: + rendered_subject = None + if template.subject_template: + rendered_subject = template.subject_template.format(**placeholders) + rendered_body = template.body_template.format(**placeholders) + except KeyError: + rendered_body = template.body_template + rendered_subject = template.subject_template + + send = CampaignSend( + template_id=template_id, + prospect_id=pid, + channel=template.channel, + rendered_subject=rendered_subject, + rendered_body=rendered_body, + status=CampaignSendStatus.SENT, + sent_at=datetime.now(UTC), + sent_by_user_id=sent_by_user_id, + ) + db.add(send) + sends.append(send) + + db.commit() + logger.info("Sent campaign %d to %d prospects", template_id, len(prospect_ids)) + return sends + + def get_sends( + self, + db: Session, + *, + prospect_id: int | None = None, + template_id: int | None = None, + ) -> list[CampaignSend]: + query = db.query(CampaignSend) + if prospect_id: + query = query.filter(CampaignSend.prospect_id == prospect_id) + if template_id: + query = query.filter(CampaignSend.template_id == template_id) + return query.order_by(CampaignSend.created_at.desc()).all() + + def _build_placeholders(self, prospect: Prospect) -> dict: + """Build template placeholder values from prospect data.""" + contacts = prospect.contacts or [] + primary_email = next((c.value for c in contacts if c.contact_type == "email"), "") + primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), "") + + reason_flags = [] + if prospect.score and prospect.score.reason_flags: + try: + reason_flags = json.loads(prospect.score.reason_flags) + except (json.JSONDecodeError, TypeError): + pass + + issues_text = ", ".join(f.replace("_", " ") for f in reason_flags) + + return { + "business_name": prospect.business_name or prospect.domain_name or "", + "domain": prospect.domain_name or "", + "score": str(prospect.score.score) if prospect.score else "—", + "issues": issues_text, + "primary_email": primary_email, + "primary_phone": primary_phone, + "city": prospect.city or "Luxembourg", + } + + +campaign_service = CampaignService() diff --git a/app/modules/prospecting/services/enrichment_service.py b/app/modules/prospecting/services/enrichment_service.py new file mode 100644 index 00000000..c54cc014 --- /dev/null +++ b/app/modules/prospecting/services/enrichment_service.py @@ -0,0 +1,369 @@ +# app/modules/prospecting/services/enrichment_service.py +""" +Enrichment service for prospect scanning pipeline. + +Migrated from marketing-.lu-domains/app/services/enrichment_service.py. +Performs passive HTTP checks, technology detection, performance audits, +and contact scraping for digital prospects. + +Uses `requests` (sync) to match Orion's tech stack. +""" + +import logging +import re +import socket +import ssl +from datetime import UTC, datetime + +import requests +from sqlalchemy.orm import Session + +from app.modules.prospecting.config import config +from app.modules.prospecting.models import ( + Prospect, + ProspectContact, + ProspectPerformanceProfile, + ProspectTechProfile, +) + +logger = logging.getLogger(__name__) + +# CMS detection patterns +CMS_PATTERNS = { + "wordpress": [r"wp-content", r"wp-includes", r"wordpress"], + "drupal": [r"drupal", r"sites/default", r"sites/all"], + "joomla": [r"/media/jui/", r"joomla", r"/components/com_"], + "shopify": [r"cdn\.shopify\.com", r"shopify"], + "wix": [r"wix\.com", r"wixstatic\.com", r"parastorage\.com"], + "squarespace": [r"squarespace\.com", r"sqsp\.com"], + "webflow": [r"webflow\.com", r"webflow\.io"], + "typo3": [r"typo3", r"/typo3conf/"], + "prestashop": [r"prestashop", r"/modules/ps_"], + "magento": [r"magento", r"mage/", r"/static/version"], +} + +JS_FRAMEWORK_PATTERNS = { + "react": [r"react", r"__NEXT_DATA__", r"_next/"], + "vue": [r"vue\.js", r"vue\.min\.js", r"__vue__"], + "angular": [r"angular", r"ng-version"], + "jquery": [r"jquery"], + "alpine": [r"alpine\.js", r"alpinejs"], +} + +ANALYTICS_PATTERNS = { + "google_analytics": [r"google-analytics\.com", r"gtag/js", r"ga\.js"], + "google_tag_manager": [r"googletagmanager\.com", r"gtm\.js"], + "matomo": [r"matomo", r"piwik"], + "facebook_pixel": [r"facebook\.net/en_US/fbevents"], +} + + +class EnrichmentService: + """Service for prospect enrichment via passive scanning.""" + + def check_http(self, db: Session, prospect: Prospect) -> dict: + """Check HTTP connectivity for a prospect's domain.""" + result = { + "has_website": False, + "uses_https": False, + "http_status_code": None, + "redirect_url": None, + "error": None, + } + + domain = prospect.domain_name + if not domain: + result["error"] = "No domain name" + return result + + # Try HTTPS first, then HTTP + for scheme in ["https", "http"]: + try: + url = f"{scheme}://{domain}" + response = requests.get( + url, + timeout=config.http_timeout, + allow_redirects=True, + verify=False, # noqa: SEC047 passive scan, not sending sensitive data + ) + result["has_website"] = True + result["uses_https"] = scheme == "https" + result["http_status_code"] = response.status_code + if response.url != url: + result["redirect_url"] = str(response.url) + break + except requests.exceptions.Timeout: + result["error"] = f"Timeout on {scheme}" + except requests.exceptions.RequestException as e: + result["error"] = str(e) + if scheme == "https": + continue + break + + # Update prospect + prospect.has_website = result["has_website"] + prospect.uses_https = result["uses_https"] + prospect.http_status_code = result["http_status_code"] + prospect.redirect_url = result["redirect_url"] + prospect.last_http_check_at = datetime.now(UTC) + + if result["has_website"]: + prospect.status = "active" + + db.commit() + return result + + def scan_tech_stack(self, db: Session, prospect: Prospect) -> ProspectTechProfile | None: + """Scan technology stack from prospect's website HTML.""" + domain = prospect.domain_name + if not domain or not prospect.has_website: + return None + + scheme = "https" if prospect.uses_https else "http" + url = f"{scheme}://{domain}" + + try: + response = requests.get( + url, + timeout=config.http_timeout, + allow_redirects=True, + verify=False, # noqa: SEC047 passive scan, not sending sensitive data + ) + html = response.text.lower() + headers = dict(response.headers) + + cms = self._detect_cms(html) + js_framework = self._detect_js_framework(html) + analytics = self._detect_analytics(html) + server = headers.get("Server", "").split("/")[0] if "Server" in headers else None + server_version = None + if server and "/" in headers.get("Server", ""): + server_version = headers["Server"].split("/", 1)[1].strip() + + # SSL certificate check + has_valid_cert = None + cert_issuer = None + cert_expires_at = None + if prospect.uses_https: + try: + ctx = ssl.create_default_context() + with ctx.wrap_socket( + socket.create_connection((domain, 443), timeout=5), + server_hostname=domain, + ) as sock: + cert = sock.getpeercert() + has_valid_cert = True + cert_issuer = dict(x[0] for x in cert.get("issuer", [()])).get("organizationName") + not_after = cert.get("notAfter") + if not_after: + cert_expires_at = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z") + except Exception: + has_valid_cert = False + + # Upsert tech profile + profile = prospect.tech_profile + if not profile: + profile = ProspectTechProfile(prospect_id=prospect.id) + db.add(profile) + + profile.cms = cms + profile.server = server + profile.server_version = server_version + profile.js_framework = js_framework + profile.analytics = analytics + profile.has_valid_cert = has_valid_cert + profile.cert_issuer = cert_issuer + profile.cert_expires_at = cert_expires_at + profile.scan_source = "basic_http" + + prospect.last_tech_scan_at = datetime.now(UTC) + db.commit() + return profile + + except Exception as e: + logger.error("Tech scan failed for %s: %s", domain, e) + if prospect.tech_profile: + prospect.tech_profile.scan_error = str(e) + prospect.last_tech_scan_at = datetime.now(UTC) + db.commit() + return None + + def scan_performance(self, db: Session, prospect: Prospect) -> ProspectPerformanceProfile | None: + """Run PageSpeed Insights audit for a prospect's website.""" + domain = prospect.domain_name + if not domain or not prospect.has_website: + return None + + scheme = "https" if prospect.uses_https else "http" + url = f"{scheme}://{domain}" + + api_url = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed" + params = { + "url": url, + "strategy": "mobile", + "category": ["performance", "accessibility", "best-practices", "seo"], + } + if config.pagespeed_api_key: + params["key"] = config.pagespeed_api_key + + try: + response = requests.get(api_url, params=params, timeout=60) + data = response.json() + + lighthouse = data.get("lighthouseResult", {}) + categories = lighthouse.get("categories", {}) + audits = lighthouse.get("audits", {}) + + perf_score = int((categories.get("performance", {}).get("score") or 0) * 100) + accessibility = int((categories.get("accessibility", {}).get("score") or 0) * 100) + best_practices = int((categories.get("best-practices", {}).get("score") or 0) * 100) + seo = int((categories.get("seo", {}).get("score") or 0) * 100) + + # Upsert performance profile + profile = prospect.performance_profile + if not profile: + profile = ProspectPerformanceProfile(prospect_id=prospect.id) + db.add(profile) + + profile.performance_score = perf_score + profile.accessibility_score = accessibility + profile.best_practices_score = best_practices + profile.seo_score = seo + + # Core Web Vitals + fcp = audits.get("first-contentful-paint", {}).get("numericValue") + profile.first_contentful_paint_ms = int(fcp) if fcp else None + lcp = audits.get("largest-contentful-paint", {}).get("numericValue") + profile.largest_contentful_paint_ms = int(lcp) if lcp else None + tbt = audits.get("total-blocking-time", {}).get("numericValue") + profile.total_blocking_time_ms = int(tbt) if tbt else None + cls_val = audits.get("cumulative-layout-shift", {}).get("numericValue") + profile.cumulative_layout_shift = cls_val + si = audits.get("speed-index", {}).get("numericValue") + profile.speed_index = int(si) if si else None + tti = audits.get("interactive", {}).get("numericValue") + profile.time_to_interactive_ms = int(tti) if tti else None + + # Mobile-friendly check + viewport = audits.get("viewport", {}).get("score") + profile.viewport_configured = viewport == 1 if viewport is not None else None + profile.is_mobile_friendly = profile.viewport_configured + profile.scan_strategy = "mobile" + + prospect.last_perf_scan_at = datetime.now(UTC) + db.commit() + return profile + + except Exception as e: + logger.error("Performance scan failed for %s: %s", domain, e) + prospect.last_perf_scan_at = datetime.now(UTC) + db.commit() + return None + + def scrape_contacts(self, db: Session, prospect: Prospect) -> list[ProspectContact]: + """Scrape email and phone contacts from prospect's website.""" + domain = prospect.domain_name + if not domain or not prospect.has_website: + return [] + + scheme = "https" if prospect.uses_https else "http" + base_url = f"{scheme}://{domain}" + paths = ["", "/contact", "/kontakt", "/impressum", "/about"] + + email_pattern = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") + phone_pattern = re.compile(r"(?:\+352|00352)?[\s.-]?\d{2,3}[\s.-]?\d{2,3}[\s.-]?\d{2,3}") + + false_positive_domains = {"example.com", "email.com", "domain.com", "wordpress.org", "w3.org", "schema.org"} + found_emails = set() + found_phones = set() + contacts = [] + + session = requests.Session() + session.verify = False # noqa: SEC047 passive scan, not sending sensitive data + session.headers.update({"User-Agent": "Mozilla/5.0 (compatible; OrionBot/1.0)"}) + + for path in paths: + try: + url = base_url + path + response = session.get(url, timeout=config.http_timeout, allow_redirects=True) + if response.status_code != 200: + continue + html = response.text + + for email in email_pattern.findall(html): + email_domain = email.split("@")[1].lower() + if email_domain not in false_positive_domains and email not in found_emails: + found_emails.add(email) + contacts.append(ProspectContact( + prospect_id=prospect.id, + contact_type="email", + value=email.lower(), + source_url=url, + source_element="regex", + )) + + for phone in phone_pattern.findall(html): + phone_clean = re.sub(r"[\s.-]", "", phone) + if len(phone_clean) >= 8 and phone_clean not in found_phones: + found_phones.add(phone_clean) + contacts.append(ProspectContact( + prospect_id=prospect.id, + contact_type="phone", + value=phone_clean, + source_url=url, + source_element="regex", + )) + except Exception as e: + logger.debug("Contact scrape failed for %s%s: %s", domain, path, e) + + session.close() + + # Save contacts (replace existing auto-scraped ones) + db.query(ProspectContact).filter( + ProspectContact.prospect_id == prospect.id, + ProspectContact.source_element == "regex", + ).delete() + + for contact in contacts: + db.add(contact) + + # Mark first email and phone as primary + if contacts: + for c in contacts: + if c.contact_type == "email": + c.is_primary = True + break + for c in contacts: + if c.contact_type == "phone": + c.is_primary = True + break + + prospect.last_contact_scrape_at = datetime.now(UTC) + db.commit() + return contacts + + def _detect_cms(self, html: str) -> str | None: + for cms, patterns in CMS_PATTERNS.items(): + for pattern in patterns: + if re.search(pattern, html): + return cms + return None + + def _detect_js_framework(self, html: str) -> str | None: + for framework, patterns in JS_FRAMEWORK_PATTERNS.items(): + for pattern in patterns: + if re.search(pattern, html): + return framework + return None + + def _detect_analytics(self, html: str) -> str | None: + found = [] + for tool, patterns in ANALYTICS_PATTERNS.items(): + for pattern in patterns: + if re.search(pattern, html): + found.append(tool) + break + return ",".join(found) if found else None + + +enrichment_service = EnrichmentService() diff --git a/app/modules/prospecting/services/interaction_service.py b/app/modules/prospecting/services/interaction_service.py new file mode 100644 index 00000000..53d2622a --- /dev/null +++ b/app/modules/prospecting/services/interaction_service.py @@ -0,0 +1,77 @@ +# app/modules/prospecting/services/interaction_service.py +""" +Interaction tracking service. + +Manages logging of all touchpoints with prospects: +calls, emails, meetings, visits, notes, etc. +""" + +import logging +from datetime import date + +from sqlalchemy.orm import Session + +from app.modules.prospecting.models import ProspectInteraction + +logger = logging.getLogger(__name__) + + +class InteractionService: + """Service for prospect interaction management.""" + + def create( + self, + db: Session, + prospect_id: int, + user_id: int, + data: dict, + ) -> ProspectInteraction: + """Log a new interaction.""" + interaction = ProspectInteraction( + prospect_id=prospect_id, + interaction_type=data["interaction_type"], + subject=data.get("subject"), + notes=data.get("notes"), + outcome=data.get("outcome"), + next_action=data.get("next_action"), + next_action_date=data.get("next_action_date"), + created_by_user_id=user_id, + ) + db.add(interaction) + db.commit() + db.refresh(interaction) + logger.info("Interaction logged for prospect %d: %s", prospect_id, data["interaction_type"]) + return interaction + + def get_for_prospect( + self, + db: Session, + prospect_id: int, + ) -> list[ProspectInteraction]: + """Get all interactions for a prospect, newest first.""" + return ( + db.query(ProspectInteraction) + .filter(ProspectInteraction.prospect_id == prospect_id) + .order_by(ProspectInteraction.created_at.desc()) + .all() + ) + + def get_upcoming_actions( + self, + db: Session, + *, + before_date: date | None = None, + ) -> list[ProspectInteraction]: + """Get interactions with upcoming follow-up actions.""" + query = db.query(ProspectInteraction).filter( + ProspectInteraction.next_action.isnot(None), + ProspectInteraction.next_action_date.isnot(None), + ) + + if before_date: + query = query.filter(ProspectInteraction.next_action_date <= before_date) + + return query.order_by(ProspectInteraction.next_action_date.asc()).all() + + +interaction_service = InteractionService() diff --git a/app/modules/prospecting/services/lead_service.py b/app/modules/prospecting/services/lead_service.py new file mode 100644 index 00000000..7f651103 --- /dev/null +++ b/app/modules/prospecting/services/lead_service.py @@ -0,0 +1,153 @@ +# app/modules/prospecting/services/lead_service.py +""" +Lead filtering and export service. + +Provides filtered views of scored prospects and CSV export capabilities. +""" + +import csv +import io +import json +import logging + +from sqlalchemy.orm import Session, joinedload + +from app.modules.prospecting.models import ( + Prospect, + ProspectScore, +) + +logger = logging.getLogger(__name__) + + +class LeadService: + """Service for lead retrieval and export.""" + + def get_leads( + self, + db: Session, + *, + page: int = 1, + per_page: int = 20, + min_score: int = 0, + max_score: int = 100, + lead_tier: str | None = None, + channel: str | None = None, + has_email: bool | None = None, + has_phone: bool | None = None, + reason_flag: str | None = None, + ) -> tuple[list[dict], int]: + """Get filtered leads with scores.""" + query = ( + db.query(Prospect) + .join(ProspectScore) + .options( + joinedload(Prospect.score), + joinedload(Prospect.contacts), + ) + .filter( + ProspectScore.score >= min_score, + ProspectScore.score <= max_score, + ) + ) + + if lead_tier: + query = query.filter(ProspectScore.lead_tier == lead_tier) + if channel: + query = query.filter(Prospect.channel == channel) + if reason_flag: + query = query.filter(ProspectScore.reason_flags.contains(reason_flag)) + + total = query.count() + prospects = ( + query.order_by(ProspectScore.score.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + + leads = [] + for p in prospects: + contacts = p.contacts or [] + primary_email = next((c.value for c in contacts if c.contact_type == "email" and c.is_primary), None) + if not primary_email: + primary_email = next((c.value for c in contacts if c.contact_type == "email"), None) + primary_phone = next((c.value for c in contacts if c.contact_type == "phone" and c.is_primary), None) + if not primary_phone: + primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None) + + # Filter by contact availability if requested + if has_email is True and not primary_email: + continue + if has_email is False and primary_email: + continue + if has_phone is True and not primary_phone: + continue + if has_phone is False and primary_phone: + continue + + reason_flags = json.loads(p.score.reason_flags) if p.score and p.score.reason_flags else [] + + leads.append({ + "id": p.id, + "business_name": p.business_name, + "domain_name": p.domain_name, + "channel": str(p.channel.value) if p.channel else None, + "score": p.score.score if p.score else 0, + "lead_tier": p.score.lead_tier if p.score else None, + "reason_flags": reason_flags, + "primary_email": primary_email, + "primary_phone": primary_phone, + }) + + return leads, total + + def get_top_priority(self, db: Session, limit: int = 50) -> list[dict]: + leads, _ = self.get_leads(db, min_score=70, per_page=limit) + return leads + + def get_quick_wins(self, db: Session, limit: int = 50) -> list[dict]: + leads, _ = self.get_leads(db, min_score=50, max_score=69, per_page=limit) + return leads + + def export_csv( + self, + db: Session, + *, + min_score: int = 0, + lead_tier: str | None = None, + channel: str | None = None, + limit: int = 1000, + ) -> str: + """Export leads to CSV string.""" + leads, _ = self.get_leads( + db, + min_score=min_score, + lead_tier=lead_tier, + channel=channel, + per_page=limit, + ) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "Domain", "Business Name", "Channel", "Score", "Tier", + "Issues", "Email", "Phone", + ]) + + for lead in leads: + writer.writerow([ + lead["domain_name"] or "", + lead["business_name"] or "", + lead["channel"] or "", + lead["score"], + lead["lead_tier"] or "", + "; ".join(lead["reason_flags"]), + lead["primary_email"] or "", + lead["primary_phone"] or "", + ]) + + return output.getvalue() + + +lead_service = LeadService() diff --git a/app/modules/prospecting/services/prospect_service.py b/app/modules/prospecting/services/prospect_service.py new file mode 100644 index 00000000..dbbea3f8 --- /dev/null +++ b/app/modules/prospecting/services/prospect_service.py @@ -0,0 +1,235 @@ +# app/modules/prospecting/services/prospect_service.py +""" +Prospect CRUD service. + +Manages creation, retrieval, update, and deletion of prospects. +Supports both digital (domain scan) and offline (manual capture) channels. +""" + +import json +import logging + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session, joinedload + +from app.modules.prospecting.exceptions import ( + DuplicateDomainException, + ProspectNotFoundException, +) +from app.modules.prospecting.models import ( + Prospect, + ProspectChannel, + ProspectContact, + ProspectScore, + ProspectStatus, +) + +logger = logging.getLogger(__name__) + + +class ProspectService: + """Service for prospect CRUD operations.""" + + def get_by_id(self, db: Session, prospect_id: int) -> Prospect: + prospect = ( + db.query(Prospect) + .options( + joinedload(Prospect.tech_profile), + joinedload(Prospect.performance_profile), + joinedload(Prospect.score), + joinedload(Prospect.contacts), + ) + .filter(Prospect.id == prospect_id) + .first() + ) + if not prospect: + raise ProspectNotFoundException(str(prospect_id)) + return prospect + + def get_by_domain(self, db: Session, domain_name: str) -> Prospect | None: + return db.query(Prospect).filter(Prospect.domain_name == domain_name).first() + + def get_all( + self, + db: Session, + *, + page: int = 1, + per_page: int = 20, + search: str | None = None, + channel: str | None = None, + status: str | None = None, + tier: str | None = None, + city: str | None = None, + has_email: bool | None = None, + has_phone: bool | None = None, + ) -> tuple[list[Prospect], int]: + query = db.query(Prospect).options( + joinedload(Prospect.score), + joinedload(Prospect.contacts), + ) + + if search: + query = query.filter( + or_( + Prospect.domain_name.ilike(f"%{search}%"), + Prospect.business_name.ilike(f"%{search}%"), + ) + ) + if channel: + query = query.filter(Prospect.channel == channel) + if status: + query = query.filter(Prospect.status == status) + if city: + query = query.filter(Prospect.city.ilike(f"%{city}%")) + if tier: + query = query.join(ProspectScore).filter(ProspectScore.lead_tier == tier) + + total = query.count() + prospects = ( + query.order_by(Prospect.created_at.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + + return prospects, total + + def create(self, db: Session, data: dict, captured_by_user_id: int | None = None) -> Prospect: + channel = data.get("channel", "digital") + + if channel == "digital" and data.get("domain_name"): + existing = self.get_by_domain(db, data["domain_name"]) + if existing: + raise DuplicateDomainException(data["domain_name"]) + + tags = data.get("tags") + if isinstance(tags, list): + tags = json.dumps(tags) + + prospect = Prospect( + channel=ProspectChannel(channel), + business_name=data.get("business_name"), + domain_name=data.get("domain_name"), + status=ProspectStatus.PENDING, + source=data.get("source", "domain_scan" if channel == "digital" else "manual"), + address=data.get("address"), + city=data.get("city"), + postal_code=data.get("postal_code"), + country=data.get("country", "LU"), + notes=data.get("notes"), + tags=tags, + captured_by_user_id=captured_by_user_id, + location_lat=data.get("location_lat"), + location_lng=data.get("location_lng"), + ) + db.add(prospect) + db.flush() + + # Create inline contacts if provided + contacts = data.get("contacts", []) + for c in contacts: + contact = ProspectContact( + prospect_id=prospect.id, + contact_type=c["contact_type"], + value=c["value"], + label=c.get("label"), + is_primary=c.get("is_primary", False), + ) + db.add(contact) + + db.commit() + db.refresh(prospect) + logger.info("Created prospect: %s (channel=%s)", prospect.display_name, channel) + return prospect + + def create_bulk(self, db: Session, domain_names: list[str], source: str = "csv_import") -> tuple[int, int]: + created = 0 + skipped = 0 + for name in domain_names: + name = name.strip().lower() + if not name: + continue + existing = self.get_by_domain(db, name) + if existing: + skipped += 1 + continue + prospect = Prospect( + channel=ProspectChannel.DIGITAL, + domain_name=name, + source=source, + ) + db.add(prospect) + created += 1 + + db.commit() + logger.info("Bulk import: %d created, %d skipped", created, skipped) + return created, skipped + + def update(self, db: Session, prospect_id: int, data: dict) -> Prospect: + prospect = self.get_by_id(db, prospect_id) + + for field in ["business_name", "status", "source", "address", "city", "postal_code", "notes"]: + if field in data and data[field] is not None: + setattr(prospect, field, data[field]) + + if "tags" in data: + tags = data["tags"] + if isinstance(tags, list): + tags = json.dumps(tags) + prospect.tags = tags + + db.commit() + db.refresh(prospect) + return prospect + + def delete(self, db: Session, prospect_id: int) -> bool: + prospect = self.get_by_id(db, prospect_id) + db.delete(prospect) + db.commit() + logger.info("Deleted prospect: %d", prospect_id) + return True + + def get_pending_http_check(self, db: Session, limit: int = 100) -> list[Prospect]: + return ( + db.query(Prospect) + .filter( + Prospect.channel == ProspectChannel.DIGITAL, + Prospect.domain_name.isnot(None), + Prospect.last_http_check_at.is_(None), + ) + .limit(limit) + .all() + ) + + def get_pending_tech_scan(self, db: Session, limit: int = 100) -> list[Prospect]: + return ( + db.query(Prospect) + .filter( + Prospect.has_website.is_(True), + Prospect.last_tech_scan_at.is_(None), + ) + .limit(limit) + .all() + ) + + def get_pending_performance_scan(self, db: Session, limit: int = 100) -> list[Prospect]: + return ( + db.query(Prospect) + .filter( + Prospect.has_website.is_(True), + Prospect.last_perf_scan_at.is_(None), + ) + .limit(limit) + .all() + ) + + def count_by_status(self, db: Session) -> dict[str, int]: + results = db.query(Prospect.status, func.count(Prospect.id)).group_by(Prospect.status).all() + return {status.value if hasattr(status, "value") else str(status): count for status, count in results} + + def count_by_channel(self, db: Session) -> dict[str, int]: + results = db.query(Prospect.channel, func.count(Prospect.id)).group_by(Prospect.channel).all() + return {channel.value if hasattr(channel, "value") else str(channel): count for channel, count in results} + + +prospect_service = ProspectService() diff --git a/app/modules/prospecting/services/scoring_service.py b/app/modules/prospecting/services/scoring_service.py new file mode 100644 index 00000000..9e0e3822 --- /dev/null +++ b/app/modules/prospecting/services/scoring_service.py @@ -0,0 +1,253 @@ +# app/modules/prospecting/services/scoring_service.py +""" +Opportunity scoring service. + +Migrated from marketing-.lu-domains/app/services/scoring_service.py. +Scores prospects on a 0-100 scale across 4 categories: +- Technical Health (max 40pts) +- Modernity (max 25pts) +- Business Value (max 25pts) +- Engagement (max 10pts) + +Extended for offline leads with additional scoring factors. +""" + +import json +import logging + +from sqlalchemy.orm import Session + +from app.modules.prospecting.models import ( + Prospect, + ProspectChannel, + ProspectScore, +) + +logger = logging.getLogger(__name__) + +# Outdated CMS list +OUTDATED_CMS = {"drupal", "joomla", "typo3"} + + +class ScoringService: + """Service for computing opportunity scores.""" + + def compute_score(self, db: Session, prospect: Prospect) -> ProspectScore: + """Compute or update the opportunity score for a prospect.""" + tech_health = 0 + modernity = 0 + business_value = 0 + engagement = 0 + reason_flags = [] + breakdown = {} + + if prospect.channel == ProspectChannel.OFFLINE: + # Offline lead scoring + tech_health, modernity, business_value, engagement, reason_flags, breakdown = ( + self._score_offline(prospect) + ) + else: + # Digital lead scoring + tech_health, modernity, business_value, engagement, reason_flags, breakdown = ( + self._score_digital(prospect) + ) + + total = min(tech_health + modernity + business_value + engagement, 100) + + # Determine lead tier + if total >= 70: + lead_tier = "top_priority" + elif total >= 50: + lead_tier = "quick_win" + elif total >= 30: + lead_tier = "strategic" + else: + lead_tier = "low_priority" + + # Upsert score + score = prospect.score + if not score: + score = ProspectScore(prospect_id=prospect.id) + db.add(score) + + score.score = total + score.technical_health_score = tech_health + score.modernity_score = modernity + score.business_value_score = business_value + score.engagement_score = engagement + score.reason_flags = json.dumps(reason_flags) + score.score_breakdown = json.dumps(breakdown) + score.lead_tier = lead_tier + + db.commit() + logger.info("Scored prospect %d: %d (%s)", prospect.id, total, lead_tier) + return score + + def compute_all(self, db: Session, limit: int | None = None) -> int: + """Compute scores for all prospects. Returns count of scored prospects.""" + query = db.query(Prospect) + if limit: + query = query.limit(limit) + + count = 0 + for prospect in query.all(): + self.compute_score(db, prospect) + count += 1 + + return count + + def _score_digital(self, prospect: Prospect) -> tuple: + """Score a digital (domain-scanned) prospect.""" + tech_health = 0 + modernity = 0 + business_value = 0 + engagement = 0 + flags = [] + breakdown = {} + + # === TECHNICAL HEALTH (max 40) === + if not prospect.uses_https: + tech_health += 15 + flags.append("no_ssl") + breakdown["no_ssl"] = 15 + + perf = prospect.performance_profile + if perf and perf.performance_score is not None: + if perf.performance_score < 30: + tech_health += 15 + flags.append("very_slow") + breakdown["very_slow"] = 15 + elif perf.performance_score < 50: + tech_health += 10 + flags.append("slow") + breakdown["slow"] = 10 + elif perf.performance_score < 70: + tech_health += 5 + flags.append("moderate_speed") + breakdown["moderate_speed"] = 5 + + if perf.is_mobile_friendly is False: + tech_health += 10 + flags.append("not_mobile_friendly") + breakdown["not_mobile_friendly"] = 10 + + tech_health = min(tech_health, 40) + + # === MODERNITY (max 25) === + tp = prospect.tech_profile + if tp: + if tp.cms and tp.cms.lower() in OUTDATED_CMS: + modernity += 15 + flags.append("outdated_cms") + breakdown["outdated_cms"] = 15 + elif tp.cms is None and prospect.has_website: + modernity += 5 + flags.append("unknown_cms") + breakdown["unknown_cms"] = 5 + + if tp.js_framework and tp.js_framework.lower() == "jquery": + modernity += 5 + flags.append("legacy_js") + breakdown["legacy_js"] = 5 + + if not tp.analytics: + modernity += 5 + flags.append("no_analytics") + breakdown["no_analytics"] = 5 + + modernity = min(modernity, 25) + + # === BUSINESS VALUE (max 25) === + if prospect.has_website: + business_value += 10 + breakdown["has_website"] = 10 + + if tp and tp.ecommerce_platform: + business_value += 10 + breakdown["has_ecommerce"] = 10 + + if prospect.domain_name and len(prospect.domain_name) <= 15: + business_value += 5 + breakdown["short_domain"] = 5 + + business_value = min(business_value, 25) + + # === ENGAGEMENT (max 10) === + contacts = prospect.contacts or [] + if contacts: + engagement += 5 + flags.append("has_contacts") + breakdown["has_contacts"] = 5 + + has_email = any(c.contact_type == "email" for c in contacts) + has_phone = any(c.contact_type == "phone" for c in contacts) + + if has_email: + engagement += 3 + flags.append("has_email") + breakdown["has_email"] = 3 + if has_phone: + engagement += 2 + flags.append("has_phone") + breakdown["has_phone"] = 2 + + engagement = min(engagement, 10) + + return tech_health, modernity, business_value, engagement, flags, breakdown + + def _score_offline(self, prospect: Prospect) -> tuple: + """Score an offline (manually captured) prospect.""" + tech_health = 0 + modernity = 0 + business_value = 0 + engagement = 0 + flags = [] + breakdown = {} + + # Offline prospects without a website are high opportunity + if not prospect.has_website: + tech_health = 30 + modernity = 20 + business_value = 20 + flags.extend(["no_website"]) + breakdown["no_website_tech"] = 30 + breakdown["no_website_mod"] = 20 + breakdown["no_website_biz"] = 20 + + # Check for gmail usage (from contacts) + contacts = prospect.contacts or [] + has_gmail = any( + c.contact_type == "email" and "@gmail." in c.value.lower() + for c in contacts + ) + if has_gmail: + modernity += 10 + flags.append("uses_gmail") + breakdown["uses_gmail"] = 10 + + modernity = min(modernity, 25) + + # Engagement - offline leads met in person are warm + if prospect.source in ("street", "networking_event", "referral"): + engagement += 5 + flags.append("met_in_person") + breakdown["met_in_person"] = 5 + + if contacts: + has_email = any(c.contact_type == "email" for c in contacts) + has_phone = any(c.contact_type == "phone" for c in contacts) + if has_email: + engagement += 3 + flags.append("has_email") + breakdown["has_email"] = 3 + if has_phone: + engagement += 2 + flags.append("has_phone") + breakdown["has_phone"] = 2 + + engagement = min(engagement, 10) + + return tech_health, modernity, business_value, engagement, flags, breakdown + + +scoring_service = ScoringService() diff --git a/app/modules/prospecting/services/stats_service.py b/app/modules/prospecting/services/stats_service.py new file mode 100644 index 00000000..40c5b548 --- /dev/null +++ b/app/modules/prospecting/services/stats_service.py @@ -0,0 +1,99 @@ +# app/modules/prospecting/services/stats_service.py +""" +Statistics service for the prospecting dashboard. +""" + +import logging + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.prospecting.models import ( + Prospect, + ProspectChannel, + ProspectScanJob, + ProspectScore, +) + +logger = logging.getLogger(__name__) + + +class StatsService: + """Service for dashboard statistics and reporting.""" + + def get_overview(self, db: Session) -> dict: + """Get overview statistics for the dashboard.""" + total = db.query(func.count(Prospect.id)).scalar() or 0 + digital = db.query(func.count(Prospect.id)).filter(Prospect.channel == ProspectChannel.DIGITAL).scalar() or 0 + offline = db.query(func.count(Prospect.id)).filter(Prospect.channel == ProspectChannel.OFFLINE).scalar() or 0 + with_website = db.query(func.count(Prospect.id)).filter(Prospect.has_website.is_(True)).scalar() or 0 + with_https = db.query(func.count(Prospect.id)).filter(Prospect.uses_https.is_(True)).scalar() or 0 + scored = db.query(func.count(ProspectScore.id)).scalar() or 0 + avg_score = db.query(func.avg(ProspectScore.score)).scalar() + + # Leads by tier + tier_results = ( + db.query(ProspectScore.lead_tier, func.count(ProspectScore.id)) + .group_by(ProspectScore.lead_tier) + .all() + ) + leads_by_tier = {tier: count for tier, count in tier_results if tier} + + # Common issues (from reason_flags JSON) + # Simplified: count scored prospects per tier + top_priority = leads_by_tier.get("top_priority", 0) + + return { + "total_prospects": total, + "digital_count": digital, + "offline_count": offline, + "with_website": with_website, + "with_https": with_https, + "scored": scored, + "avg_score": round(avg_score, 1) if avg_score else None, + "top_priority": top_priority, + "leads_by_tier": leads_by_tier, + "common_issues": self._get_common_issues(db), + } + + def get_scan_jobs( + self, + db: Session, + *, + page: int = 1, + per_page: int = 20, + status: str | None = None, + ) -> tuple[list[ProspectScanJob], int]: + """Get paginated scan jobs.""" + query = db.query(ProspectScanJob) + if status: + query = query.filter(ProspectScanJob.status == status) + + total = query.count() + jobs = ( + query.order_by(ProspectScanJob.created_at.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + return jobs, total + + def _get_common_issues(self, db: Session) -> list[dict]: + """Extract common issue flags from scored prospects.""" + scores = db.query(ProspectScore.reason_flags).filter(ProspectScore.reason_flags.isnot(None)).all() + + import json + flag_counts: dict[str, int] = {} + for (flags_json,) in scores: + try: + flags = json.loads(flags_json) + for flag in flags: + flag_counts[flag] = flag_counts.get(flag, 0) + 1 + except (json.JSONDecodeError, TypeError): + continue + + sorted_flags = sorted(flag_counts.items(), key=lambda x: x[1], reverse=True) + return [{"flag": flag, "count": count} for flag, count in sorted_flags[:10]] + + +stats_service = StatsService() diff --git a/app/modules/prospecting/static/admin/js/campaigns.js b/app/modules/prospecting/static/admin/js/campaigns.js new file mode 100644 index 00000000..697a23eb --- /dev/null +++ b/app/modules/prospecting/static/admin/js/campaigns.js @@ -0,0 +1,137 @@ +// static/admin/js/campaigns.js + +const campLog = window.LogConfig.createLogger('prospecting-campaigns'); + +function campaignManager() { + return { + ...data(), + + currentPage: 'campaigns', + + templates: [], + loading: true, + error: null, + filterLeadType: '', + + showCreateModal: false, + showEditModal: false, + editingTemplateId: null, + + templateForm: { + name: '', + lead_type: 'no_website', + channel: 'email', + language: 'fr', + subject_template: '', + body_template: '', + is_active: true, + }, + + leadTypes: [ + { value: 'no_website', label: 'No Website' }, + { value: 'bad_website', label: 'Bad Website' }, + { value: 'gmail_only', label: 'Gmail Only' }, + { value: 'security_issues', label: 'Security Issues' }, + { value: 'performance_issues', label: 'Performance Issues' }, + { value: 'outdated_cms', label: 'Outdated CMS' }, + { value: 'general', label: 'General' }, + ], + + placeholders: [ + '{business_name}', '{domain}', '{score}', '{issues}', + '{primary_email}', '{primary_phone}', '{city}', + ], + + async init() { + await I18n.loadModule('prospecting'); + + if (window._campaignsInit) return; + window._campaignsInit = true; + + campLog.info('Campaign manager initializing'); + await this.loadTemplates(); + }, + + async loadTemplates() { + this.loading = true; + this.error = null; + try { + const response = await apiClient.get('/admin/prospecting/campaigns/templates'); + this.templates = response.items || response || []; + } catch (err) { + this.error = err.message; + campLog.error('Failed to load templates', err); + } finally { + this.loading = false; + } + }, + + filteredTemplates() { + if (!this.filterLeadType) return this.templates; + return this.templates.filter(t => t.lead_type === this.filterLeadType); + }, + + editTemplate(tpl) { + this.editingTemplateId = tpl.id; + this.templateForm = { + name: tpl.name, + lead_type: tpl.lead_type, + channel: tpl.channel, + language: tpl.language, + subject_template: tpl.subject_template || '', + body_template: tpl.body_template, + is_active: tpl.is_active, + }; + this.showEditModal = true; + }, + + async saveTemplate() { + try { + if (this.showEditModal && this.editingTemplateId) { + await apiClient.put( + '/admin/prospecting/campaigns/templates/' + this.editingTemplateId, + this.templateForm, + ); + Utils.showToast('Template updated', 'success'); + } else { + await apiClient.post('/admin/prospecting/campaigns/templates', this.templateForm); + Utils.showToast('Template created', 'success'); + } + this.showCreateModal = false; + this.showEditModal = false; + this.resetForm(); + await this.loadTemplates(); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + } + }, + + async deleteTemplate(id) { + if (!confirm('Delete this template?')) return; + try { + await apiClient.delete('/admin/prospecting/campaigns/templates/' + id); + Utils.showToast('Template deleted', 'success'); + await this.loadTemplates(); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + } + }, + + insertPlaceholder(ph) { + this.templateForm.body_template += ph; + }, + + resetForm() { + this.editingTemplateId = null; + this.templateForm = { + name: '', + lead_type: 'no_website', + channel: 'email', + language: 'fr', + subject_template: '', + body_template: '', + is_active: true, + }; + }, + }; +} diff --git a/app/modules/prospecting/static/admin/js/capture.js b/app/modules/prospecting/static/admin/js/capture.js new file mode 100644 index 00000000..d92f7184 --- /dev/null +++ b/app/modules/prospecting/static/admin/js/capture.js @@ -0,0 +1,153 @@ +// noqa: js-006 - async init pattern is safe, no data loading +// static/admin/js/capture.js + +const captureLog = window.LogConfig.createLogger('prospecting-capture'); + +function quickCapture() { + return { + ...data(), + + currentPage: 'capture', + + form: { + business_name: '', + phone: '', + email: '', + address: '', + city: '', + postal_code: '', + source: 'street', + notes: '', + tags: [], + location_lat: null, + location_lng: null, + }, + + sources: [ + { value: 'street', label: 'Street' }, + { value: 'networking_event', label: 'Networking' }, + { value: 'referral', label: 'Referral' }, + { value: 'other', label: 'Other' }, + ], + + availableTags: [ + 'no-website', 'gmail', 'restaurant', 'retail', 'services', + 'professional', 'construction', 'beauty', 'food', + ], + + submitting: false, + saved: false, + lastSaved: '', + gettingLocation: false, + recentCaptures: [], + + async init() { + await I18n.loadModule('prospecting'); + + if (window._captureInit) return; + window._captureInit = true; + + captureLog.info('Quick capture initializing'); + }, + + toggleTag(tag) { + const idx = this.form.tags.indexOf(tag); + if (idx >= 0) { + this.form.tags.splice(idx, 1); + } else { + this.form.tags.push(tag); + } + }, + + getLocation() { + if (!navigator.geolocation) { + Utils.showToast('Geolocation not supported', 'error'); + return; + } + this.gettingLocation = true; + navigator.geolocation.getCurrentPosition( + (pos) => { + this.form.location_lat = pos.coords.latitude; + this.form.location_lng = pos.coords.longitude; + this.gettingLocation = false; + captureLog.info('Location acquired', pos.coords); + }, + (err) => { + this.gettingLocation = false; + Utils.showToast('Location error: ' + err.message, 'error'); + captureLog.error('Geolocation error', err); + }, + { enableHighAccuracy: true, timeout: 10000 }, + ); + }, + + async submitCapture() { + if (!this.form.business_name) { + Utils.showToast('Business name is required', 'error'); + return; + } + + this.submitting = true; + try { + const payload = { + channel: 'offline', + business_name: this.form.business_name, + source: this.form.source, + notes: this.form.notes, + tags: this.form.tags.length > 0 ? this.form.tags : null, + address: this.form.address || null, + city: this.form.city || null, + postal_code: this.form.postal_code || null, + location_lat: this.form.location_lat, + location_lng: this.form.location_lng, + contacts: [], + }; + + if (this.form.phone) { + payload.contacts.push({ contact_type: 'phone', value: this.form.phone }); + } + if (this.form.email) { + payload.contacts.push({ contact_type: 'email', value: this.form.email }); + } + + const result = await apiClient.post('/admin/prospecting/prospects', payload); + + this.lastSaved = this.form.business_name; + this.recentCaptures.unshift({ + id: result.id, + business_name: this.form.business_name, + city: this.form.city, + source: this.form.source, + }); + + this.saved = true; + setTimeout(() => { this.saved = false; }, 3000); + + this.resetForm(); + Utils.showToast('Prospect saved!', 'success'); + captureLog.info('Capture saved', result.id); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + captureLog.error('Capture failed', err); + } finally { + this.submitting = false; + } + }, + + resetForm() { + this.form = { + business_name: '', + phone: '', + email: '', + address: '', + city: this.form.city, // Keep city for rapid captures in same area + postal_code: this.form.postal_code, + source: this.form.source, + notes: '', + tags: [], + location_lat: this.form.location_lat, // Keep GPS + location_lng: this.form.location_lng, + }; + }, + }; +} diff --git a/app/modules/prospecting/static/admin/js/dashboard.js b/app/modules/prospecting/static/admin/js/dashboard.js new file mode 100644 index 00000000..93c9b135 --- /dev/null +++ b/app/modules/prospecting/static/admin/js/dashboard.js @@ -0,0 +1,84 @@ +// static/admin/js/dashboard.js + +const dashLog = window.LogConfig.createLogger('prospecting-dashboard'); + +function prospectingDashboard() { + return { + ...data(), + + currentPage: 'prospecting-dashboard', + + stats: { + total_prospects: 0, + digital_count: 0, + offline_count: 0, + top_priority: 0, + avg_score: null, + leads_by_tier: {}, + common_issues: [], + }, + loading: true, + error: null, + showImportModal: false, + + async init() { + await I18n.loadModule('prospecting'); + + if (window._prospectingDashboardInit) return; + window._prospectingDashboardInit = true; + + dashLog.info('Dashboard initializing'); + await this.loadStats(); + }, + + async loadStats() { + this.loading = true; + this.error = null; + try { + const response = await apiClient.get('/admin/prospecting/stats'); + Object.assign(this.stats, response); + dashLog.info('Stats loaded', this.stats); + } catch (err) { + this.error = err.message; + dashLog.error('Failed to load stats', err); + } finally { + this.loading = false; + } + }, + + async runBatchScan() { + try { + await apiClient.post('/admin/prospecting/enrichment/http-check/batch'); + Utils.showToast('Batch scan started', 'success'); + } catch (err) { + Utils.showToast('Failed to start batch scan: ' + err.message, 'error'); + } + }, + + tierBadgeClass(tier) { + const classes = { + top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700', + quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700', + strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700', + low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700', + }; + return classes[tier] || classes.low_priority; + }, + + issueLabel(flag) { + const labels = { + no_ssl: 'No SSL/HTTPS', + very_slow: 'Very Slow', + slow: 'Slow', + not_mobile_friendly: 'Not Mobile Friendly', + outdated_cms: 'Outdated CMS', + unknown_cms: 'Unknown CMS', + legacy_js: 'Legacy JavaScript', + no_analytics: 'No Analytics', + no_website: 'No Website', + uses_gmail: 'Uses Gmail', + }; + return labels[flag] || flag.replace(/_/g, ' '); + }, + }; +} diff --git a/app/modules/prospecting/static/admin/js/leads.js b/app/modules/prospecting/static/admin/js/leads.js new file mode 100644 index 00000000..946e7be4 --- /dev/null +++ b/app/modules/prospecting/static/admin/js/leads.js @@ -0,0 +1,107 @@ +// static/admin/js/leads.js + +const leadsLog = window.LogConfig.createLogger('prospecting-leads'); + +function leadsList() { + return { + ...data(), + + currentPage: 'leads', + + leads: [], + loading: true, + error: null, + + // Filters + minScore: 0, + filterTier: '', + filterChannel: '', + filterIssue: '', + filterHasEmail: '', + + // Pagination + pagination: { page: 1, per_page: 20, total: 0, pages: 0 }, + + async init() { + await I18n.loadModule('prospecting'); + + if (window._leadsListInit) return; + window._leadsListInit = true; + + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + leadsLog.info('Leads list initializing'); + await this.loadLeads(); + }, + + async loadLeads() { + this.loading = true; + this.error = null; + try { + const params = new URLSearchParams({ + page: this.pagination.page, + per_page: this.pagination.per_page, + }); + if (this.minScore > 0) params.set('min_score', this.minScore); + if (this.filterTier) params.set('lead_tier', this.filterTier); + if (this.filterChannel) params.set('channel', this.filterChannel); + if (this.filterIssue) params.set('reason_flag', this.filterIssue); + if (this.filterHasEmail) params.set('has_email', this.filterHasEmail); + + const response = await apiClient.get('/admin/prospecting/leads?' + params); + this.leads = response.items || []; + this.pagination.total = response.total || 0; + this.pagination.pages = response.pages || 0; + } catch (err) { + this.error = err.message; + leadsLog.error('Failed to load leads', err); + } finally { + this.loading = false; + } + }, + + async exportCSV() { + try { + const params = new URLSearchParams(); + if (this.minScore > 0) params.set('min_score', this.minScore); + if (this.filterTier) params.set('lead_tier', this.filterTier); + if (this.filterChannel) params.set('channel', this.filterChannel); + + const url = '/admin/prospecting/leads/export/csv?' + params; + window.open('/api/v1' + url, '_blank'); + Utils.showToast('CSV export started', 'success'); + } catch (err) { + Utils.showToast('Export failed: ' + err.message, 'error'); + } + }, + + sendCampaign(lead) { + window.location.href = '/admin/prospecting/prospects/' + lead.id + '#campaigns'; + }, + + goToPage(page) { + this.pagination.page = page; + this.loadLeads(); + }, + + tierBadgeClass(tier) { + const classes = { + top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700', + quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700', + strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700', + low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700', + }; + return classes[tier] || classes.low_priority; + }, + + scoreColor(score) { + if (score == null) return 'text-gray-400'; + if (score >= 70) return 'text-red-600'; + if (score >= 50) return 'text-orange-600'; + if (score >= 30) return 'text-blue-600'; + return 'text-gray-600'; + }, + }; +} diff --git a/app/modules/prospecting/static/admin/js/prospect-detail.js b/app/modules/prospecting/static/admin/js/prospect-detail.js new file mode 100644 index 00000000..072b0c81 --- /dev/null +++ b/app/modules/prospecting/static/admin/js/prospect-detail.js @@ -0,0 +1,141 @@ +// static/admin/js/prospect-detail.js + +const detailLog = window.LogConfig.createLogger('prospecting-detail'); + +function prospectDetail(prospectId) { + return { + ...data(), + + currentPage: 'prospects', + prospectId: prospectId, + prospect: null, + interactions: [], + campaignSends: [], + loading: true, + error: null, + + activeTab: 'overview', + tabs: [ + { id: 'overview', label: 'Overview' }, + { id: 'interactions', label: 'Interactions' }, + { id: 'campaigns', label: 'Campaigns' }, + ], + + // Interaction modal + showInteractionModal: false, + newInteraction: { + interaction_type: 'note', + subject: '', + notes: '', + outcome: '', + next_action: '', + }, + + // Campaign modal + showSendCampaignModal: false, + + async init() { + await I18n.loadModule('prospecting'); + + if (window._prospectDetailInit) return; + window._prospectDetailInit = true; + + detailLog.info('Prospect detail initializing for ID:', this.prospectId); + await this.loadProspect(); + }, + + async loadProspect() { + this.loading = true; + this.error = null; + try { + this.prospect = await apiClient.get('/admin/prospecting/prospects/' + this.prospectId); + await Promise.all([ + this.loadInteractions(), + this.loadCampaignSends(), + ]); + } catch (err) { + this.error = err.message; + detailLog.error('Failed to load prospect', err); + } finally { + this.loading = false; + } + }, + + async loadInteractions() { + try { + const resp = await apiClient.get('/admin/prospecting/prospects/' + this.prospectId + '/interactions'); + this.interactions = resp.items || resp || []; + } catch (err) { + detailLog.warn('Failed to load interactions', err); + } + }, + + async loadCampaignSends() { + try { + const resp = await apiClient.get('/admin/prospecting/campaigns/sends?prospect_id=' + this.prospectId); + this.campaignSends = resp.items || resp || []; + } catch (err) { + detailLog.warn('Failed to load campaign sends', err); + } + }, + + async updateStatus() { + try { + await apiClient.put('/admin/prospecting/prospects/' + this.prospectId, { + status: this.prospect.status, + }); + Utils.showToast('Status updated', 'success'); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + } + }, + + async runEnrichment() { + try { + await apiClient.post('/admin/prospecting/enrichment/full/' + this.prospectId); + Utils.showToast('Enrichment scan started', 'success'); + setTimeout(() => this.loadProspect(), 5000); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + } + }, + + async createInteraction() { + try { + await apiClient.post( + '/admin/prospecting/prospects/' + this.prospectId + '/interactions', + this.newInteraction, + ); + Utils.showToast('Interaction logged', 'success'); + this.showInteractionModal = false; + this.newInteraction = { interaction_type: 'note', subject: '', notes: '', outcome: '', next_action: '' }; + await this.loadInteractions(); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + } + }, + + scoreColor(score) { + if (score == null) return 'text-gray-400'; + if (score >= 70) return 'text-red-600'; + if (score >= 50) return 'text-orange-600'; + if (score >= 30) return 'text-blue-600'; + return 'text-gray-600'; + }, + + techProfileEntries() { + const tp = this.prospect?.tech_profile; + if (!tp) return []; + const entries = []; + if (tp.cms) entries.push(['CMS', tp.cms + (tp.cms_version ? ' ' + tp.cms_version : '')]); + if (tp.server) entries.push(['Server', tp.server]); + if (tp.js_framework) entries.push(['JS Framework', tp.js_framework]); + if (tp.analytics) entries.push(['Analytics', tp.analytics]); + if (tp.ecommerce_platform) entries.push(['E-commerce', tp.ecommerce_platform]); + if (tp.hosting_provider) entries.push(['Hosting', tp.hosting_provider]); + if (tp.cdn) entries.push(['CDN', tp.cdn]); + entries.push(['SSL Valid', tp.has_valid_cert ? 'Yes' : 'No']); + return entries; + }, + }; +} diff --git a/app/modules/prospecting/static/admin/js/prospects.js b/app/modules/prospecting/static/admin/js/prospects.js new file mode 100644 index 00000000..762ed36d --- /dev/null +++ b/app/modules/prospecting/static/admin/js/prospects.js @@ -0,0 +1,149 @@ +// static/admin/js/prospects.js + +const prospectsLog = window.LogConfig.createLogger('prospecting-prospects'); + +function prospectsList() { + return { + ...data(), + + currentPage: 'prospects', + + prospects: [], + loading: true, + error: null, + + // Filters + search: '', + filterChannel: '', + filterStatus: '', + filterTier: '', + + // Pagination + pagination: { page: 1, per_page: 20, total: 0, pages: 0 }, + + // Create modal + showCreateModal: false, + creating: false, + newProspect: { + channel: 'digital', + domain_name: '', + business_name: '', + phone: '', + email: '', + city: '', + source: 'street', + notes: '', + }, + + async init() { + await I18n.loadModule('prospecting'); + + if (window._prospectsListInit) return; + window._prospectsListInit = true; + + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + prospectsLog.info('Prospects list initializing'); + await this.loadProspects(); + }, + + async loadProspects() { + this.loading = true; + this.error = null; + try { + const params = new URLSearchParams({ + page: this.pagination.page, + per_page: this.pagination.per_page, + }); + if (this.search) params.set('search', this.search); + if (this.filterChannel) params.set('channel', this.filterChannel); + if (this.filterStatus) params.set('status', this.filterStatus); + if (this.filterTier) params.set('tier', this.filterTier); + + const response = await apiClient.get('/admin/prospecting/prospects?' + params); + this.prospects = response.items || []; + this.pagination.total = response.total || 0; + this.pagination.pages = response.pages || 0; + } catch (err) { + this.error = err.message; + prospectsLog.error('Failed to load prospects', err); + } finally { + this.loading = false; + } + }, + + async createProspect() { + this.creating = true; + try { + const payload = { ...this.newProspect }; + if (payload.channel === 'digital') { + delete payload.business_name; + delete payload.phone; + delete payload.email; + delete payload.city; + } else { + delete payload.domain_name; + } + await apiClient.post('/admin/prospecting/prospects', payload); + Utils.showToast('Prospect created', 'success'); + this.showCreateModal = false; + this.resetNewProspect(); + await this.loadProspects(); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + } finally { + this.creating = false; + } + }, + + resetNewProspect() { + this.newProspect = { + channel: 'digital', + domain_name: '', + business_name: '', + phone: '', + email: '', + city: '', + source: 'street', + notes: '', + }; + }, + + goToPage(page) { + this.pagination.page = page; + this.loadProspects(); + }, + + statusBadgeClass(status) { + const classes = { + pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700', + active: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700', + contacted: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700', + converted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700', + inactive: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700', + error: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700', + }; + return classes[status] || classes.pending; + }, + + tierBadgeClass(tier) { + const classes = { + top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700', + quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700', + strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700', + low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700', + }; + return classes[tier] || classes.low_priority; + }, + + scoreColor(score) { + if (score == null) return 'text-gray-400'; + if (score >= 70) return 'text-red-600'; + if (score >= 50) return 'text-orange-600'; + if (score >= 30) return 'text-blue-600'; + return 'text-gray-600'; + }, + }; +} diff --git a/app/modules/prospecting/static/admin/js/scan-jobs.js b/app/modules/prospecting/static/admin/js/scan-jobs.js new file mode 100644 index 00000000..e64e9c82 --- /dev/null +++ b/app/modules/prospecting/static/admin/js/scan-jobs.js @@ -0,0 +1,87 @@ +// static/admin/js/scan-jobs.js + +const jobsLog = window.LogConfig.createLogger('prospecting-scan-jobs'); + +function scanJobs() { + return { + ...data(), + + currentPage: 'scan-jobs', + + jobs: [], + loading: true, + error: null, + pagination: { page: 1, per_page: 20, total: 0, pages: 0 }, + + async init() { + await I18n.loadModule('prospecting'); + + if (window._scanJobsInit) return; + window._scanJobsInit = true; + + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + jobsLog.info('Scan jobs initializing'); + await this.loadJobs(); + }, + + async loadJobs() { + this.loading = true; + this.error = null; + try { + const params = new URLSearchParams({ + page: this.pagination.page, + per_page: this.pagination.per_page, + }); + const response = await apiClient.get('/admin/prospecting/stats/jobs?' + params); + this.jobs = response.items || []; + this.pagination.total = response.total || 0; + this.pagination.pages = response.pages || 0; + } catch (err) { + this.error = err.message; + jobsLog.error('Failed to load jobs', err); + } finally { + this.loading = false; + } + }, + + async startBatchJob(jobType) { + try { + await apiClient.post('/admin/prospecting/enrichment/' + jobType.replace('_', '-') + '/batch'); + Utils.showToast(jobType.replace(/_/g, ' ') + ' batch started', 'success'); + setTimeout(() => this.loadJobs(), 2000); + } catch (err) { + Utils.showToast('Failed: ' + err.message, 'error'); + } + }, + + goToPage(page) { + this.pagination.page = page; + this.loadJobs(); + }, + + jobStatusClass(status) { + const classes = { + pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700', + running: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700', + completed: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700', + failed: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700', + cancelled: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700', + }; + return classes[status] || classes.pending; + }, + + formatDuration(job) { + if (!job.started_at) return '—'; + const start = new Date(job.started_at); + const end = job.completed_at ? new Date(job.completed_at) : new Date(); + const seconds = Math.round((end - start) / 1000); + if (seconds < 60) return seconds + 's'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return mins + 'm ' + secs + 's'; + }, + }; +} diff --git a/app/modules/prospecting/tasks/__init__.py b/app/modules/prospecting/tasks/__init__.py new file mode 100644 index 00000000..84fd8b38 --- /dev/null +++ b/app/modules/prospecting/tasks/__init__.py @@ -0,0 +1 @@ +# app/modules/prospecting/tasks/__init__.py diff --git a/app/modules/prospecting/tasks/scan_tasks.py b/app/modules/prospecting/tasks/scan_tasks.py new file mode 100644 index 00000000..91e0b8a8 --- /dev/null +++ b/app/modules/prospecting/tasks/scan_tasks.py @@ -0,0 +1,325 @@ +# app/modules/prospecting/tasks/scan_tasks.py +""" +Celery tasks for batch prospect scanning and enrichment. +""" + +import logging +from datetime import UTC, datetime + +from app.core.celery_config import celery_app +from app.modules.prospecting.models import ProspectScanJob +from app.modules.task_base import ModuleTask + +logger = logging.getLogger(__name__) + + +@celery_app.task( + bind=True, + base=ModuleTask, + name="app.modules.prospecting.tasks.scan_tasks.batch_http_check", + max_retries=2, + default_retry_delay=60, + autoretry_for=(Exception,), + retry_backoff=True, + retry_backoff_max=300, +) +def batch_http_check(self, job_id: int, limit: int = 100): + """Run HTTP connectivity check for pending prospects.""" + with self.get_db() as db: + job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first() + if not job: + logger.error("Scan job %d not found", job_id) + return + + job.celery_task_id = self.request.id + job.status = "running" + job.started_at = datetime.now(UTC) + db.flush() + + try: + from app.modules.prospecting.services.enrichment_service import ( + enrichment_service, + ) + from app.modules.prospecting.services.prospect_service import ( + prospect_service, + ) + + prospects = prospect_service.get_pending_http_check(db, limit=limit) + job.total_items = len(prospects) + db.flush() + + for processed, prospect in enumerate(prospects, 1): + enrichment_service.check_http(db, prospect) + job.processed_items = processed + if processed % 10 == 0: + db.flush() + + job.status = "completed" + job.completed_at = datetime.now(UTC) + + except Exception as e: + logger.error("batch_http_check job %d failed: %s", job_id, e, exc_info=True) + job.status = "failed" + job.error_message = str(e)[:500] + job.completed_at = datetime.now(UTC) + raise + + +@celery_app.task( + bind=True, + base=ModuleTask, + name="app.modules.prospecting.tasks.scan_tasks.batch_tech_scan", + max_retries=2, + default_retry_delay=60, + autoretry_for=(Exception,), + retry_backoff=True, + retry_backoff_max=300, +) +def batch_tech_scan(self, job_id: int, limit: int = 100): + """Run technology scan for pending prospects.""" + with self.get_db() as db: + job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first() + if not job: + logger.error("Scan job %d not found", job_id) + return + + job.celery_task_id = self.request.id + job.status = "running" + job.started_at = datetime.now(UTC) + db.flush() + + try: + from app.modules.prospecting.services.enrichment_service import ( + enrichment_service, + ) + from app.modules.prospecting.services.prospect_service import ( + prospect_service, + ) + + prospects = prospect_service.get_pending_tech_scan(db, limit=limit) + job.total_items = len(prospects) + db.flush() + + successful = 0 + for processed, prospect in enumerate(prospects, 1): + result = enrichment_service.scan_tech_stack(db, prospect) + if result: + successful += 1 + job.processed_items = processed + if processed % 10 == 0: + db.flush() + + job.status = "completed" + job.completed_at = datetime.now(UTC) + + except Exception as e: + logger.error("batch_tech_scan job %d failed: %s", job_id, e, exc_info=True) + job.status = "failed" + job.error_message = str(e)[:500] + job.completed_at = datetime.now(UTC) + raise + + +@celery_app.task( + bind=True, + base=ModuleTask, + name="app.modules.prospecting.tasks.scan_tasks.batch_performance_scan", + max_retries=2, + default_retry_delay=120, + autoretry_for=(Exception,), + retry_backoff=True, + retry_backoff_max=600, +) +def batch_performance_scan(self, job_id: int, limit: int = 50): + """Run PageSpeed performance scan for pending prospects.""" + with self.get_db() as db: + job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first() + if not job: + logger.error("Scan job %d not found", job_id) + return + + job.celery_task_id = self.request.id + job.status = "running" + job.started_at = datetime.now(UTC) + db.flush() + + try: + from app.modules.prospecting.services.enrichment_service import ( + enrichment_service, + ) + from app.modules.prospecting.services.prospect_service import ( + prospect_service, + ) + + prospects = prospect_service.get_pending_performance_scan(db, limit=limit) + job.total_items = len(prospects) + db.flush() + + successful = 0 + for processed, prospect in enumerate(prospects, 1): + result = enrichment_service.scan_performance(db, prospect) + if result: + successful += 1 + job.processed_items = processed + if processed % 5 == 0: + db.flush() + + job.status = "completed" + job.completed_at = datetime.now(UTC) + + except Exception as e: + logger.error("batch_performance_scan job %d failed: %s", job_id, e, exc_info=True) + job.status = "failed" + job.error_message = str(e)[:500] + job.completed_at = datetime.now(UTC) + raise + + +@celery_app.task( + bind=True, + base=ModuleTask, + name="app.modules.prospecting.tasks.scan_tasks.batch_contact_scrape", + max_retries=2, + default_retry_delay=60, + autoretry_for=(Exception,), + retry_backoff=True, + retry_backoff_max=300, +) +def batch_contact_scrape(self, job_id: int, limit: int = 100): + """Scrape contacts for pending prospects.""" + with self.get_db() as db: + job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first() + if not job: + logger.error("Scan job %d not found", job_id) + return + + job.celery_task_id = self.request.id + job.status = "running" + job.started_at = datetime.now(UTC) + db.flush() + + try: + from app.modules.prospecting.services.enrichment_service import ( + enrichment_service, + ) + from app.modules.prospecting.services.prospect_service import ( + prospect_service, + ) + + prospects = prospect_service.get_pending_http_check(db, limit=limit) + # Only scrape those with websites + prospects = [p for p in prospects if p.has_website] + job.total_items = len(prospects) + db.flush() + + for processed, prospect in enumerate(prospects, 1): + enrichment_service.scrape_contacts(db, prospect) + job.processed_items = processed + if processed % 10 == 0: + db.flush() + + job.status = "completed" + job.completed_at = datetime.now(UTC) + + except Exception as e: + logger.error("batch_contact_scrape job %d failed: %s", job_id, e, exc_info=True) + job.status = "failed" + job.error_message = str(e)[:500] + job.completed_at = datetime.now(UTC) + raise + + +@celery_app.task( + bind=True, + base=ModuleTask, + name="app.modules.prospecting.tasks.scan_tasks.batch_score_compute", + max_retries=1, + default_retry_delay=30, +) +def batch_score_compute(self, job_id: int, limit: int = 500): + """Compute or recompute scores for all prospects.""" + with self.get_db() as db: + job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first() + if not job: + logger.error("Scan job %d not found", job_id) + return + + job.celery_task_id = self.request.id + job.status = "running" + job.started_at = datetime.now(UTC) + db.flush() + + try: + from app.modules.prospecting.services.scoring_service import scoring_service + + count = scoring_service.compute_all(db, limit=limit) + job.processed_items = count + job.total_items = count + job.status = "completed" + job.completed_at = datetime.now(UTC) + + except Exception as e: + logger.error("batch_score_compute job %d failed: %s", job_id, e, exc_info=True) + job.status = "failed" + job.error_message = str(e)[:500] + job.completed_at = datetime.now(UTC) + raise + + +@celery_app.task( + bind=True, + base=ModuleTask, + name="app.modules.prospecting.tasks.scan_tasks.full_enrichment", + max_retries=2, + default_retry_delay=60, + autoretry_for=(Exception,), + retry_backoff=True, +) +def full_enrichment(self, job_id: int, prospect_id: int): + """Run full enrichment pipeline for a single prospect.""" + with self.get_db() as db: + job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first() + if not job: + logger.error("Scan job %d not found", job_id) + return + + job.celery_task_id = self.request.id + job.status = "running" + job.started_at = datetime.now(UTC) + job.total_items = 1 + db.flush() + + try: + from app.modules.prospecting.services.enrichment_service import ( + enrichment_service, + ) + from app.modules.prospecting.services.prospect_service import ( + prospect_service, + ) + from app.modules.prospecting.services.scoring_service import scoring_service + + prospect = prospect_service.get_by_id(db, prospect_id) + + # HTTP check + enrichment_service.check_http(db, prospect) + + # Tech + Performance + Contacts (if has website) + if prospect.has_website: + enrichment_service.scan_tech_stack(db, prospect) + enrichment_service.scan_performance(db, prospect) + enrichment_service.scrape_contacts(db, prospect) + + # Score + db.refresh(prospect) + scoring_service.compute_score(db, prospect) + + job.processed_items = 1 + job.status = "completed" + job.completed_at = datetime.now(UTC) + + except Exception as e: + logger.error("full_enrichment job %d failed: %s", job_id, e, exc_info=True) + job.status = "failed" + job.error_message = str(e)[:500] + job.completed_at = datetime.now(UTC) + raise diff --git a/app/modules/prospecting/templates/prospecting/admin/campaigns.html b/app/modules/prospecting/templates/prospecting/admin/campaigns.html new file mode 100644 index 00000000..737f8e59 --- /dev/null +++ b/app/modules/prospecting/templates/prospecting/admin/campaigns.html @@ -0,0 +1,143 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Campaigns{% endblock %} + +{% block alpine_data %}campaignManager(){% endblock %} + +{% block content %} +{{ page_header('Campaign Templates', action_label='New Template', action_onclick='showCreateModal = true', action_icon='plus') }} + + +
+ + +
+ +{{ loading_state('Loading templates...') }} +{{ error_state('Error loading templates') }} + + +
+ +
+ + +
+
+
+

+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ Placeholders: + +
+
+
+ + +
+
+
+ + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/prospecting/templates/prospecting/admin/capture.html b/app/modules/prospecting/templates/prospecting/admin/capture.html new file mode 100644 index 00000000..4064b1b3 --- /dev/null +++ b/app/modules/prospecting/templates/prospecting/admin/capture.html @@ -0,0 +1,137 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state %} + +{% block title %}Quick Capture{% endblock %} + +{% block alpine_data %}quickCapture(){% endblock %} + +{% block content %} +{{ page_header('Quick Capture') }} + +
+ +
+ + +
+ +
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ + +
+ + +
+ + + , + +
+ + + +
+ + +
+

+ Recent Captures () +

+
+ +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/prospecting/templates/prospecting/admin/dashboard.html b/app/modules/prospecting/templates/prospecting/admin/dashboard.html new file mode 100644 index 00000000..b4ffd714 --- /dev/null +++ b/app/modules/prospecting/templates/prospecting/admin/dashboard.html @@ -0,0 +1,127 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Prospecting Dashboard{% endblock %} + +{% block alpine_data %}prospectingDashboard(){% endblock %} + +{% block content %} +{{ page_header('Prospecting Dashboard') }} +{{ loading_state('Loading dashboard...') }} +{{ error_state('Error loading dashboard') }} + +
+ +
+
+
+ +
+
+

Total Prospects

+

+
+
+
+
+ +
+
+

Top Priority

+

+
+
+
+
+ +
+
+

Avg Score

+

+
+
+
+
+ +
+
+

Offline Leads

+

+
+
+
+ + +
+ + + Quick Capture + + + +
+ + +
+
+

Leads by Tier

+
+ +
+
+
+

By Channel

+
+
+ Digital (Domain Scan) + +
+
+ Offline (Captured) + +
+
+
+
+ + +
+

Common Issues

+
+ +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/prospecting/templates/prospecting/admin/leads.html b/app/modules/prospecting/templates/prospecting/admin/leads.html new file mode 100644 index 00000000..3835121e --- /dev/null +++ b/app/modules/prospecting/templates/prospecting/admin/leads.html @@ -0,0 +1,127 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/pagination.html' import pagination_controls %} + +{% block title %}Leads{% endblock %} + +{% block alpine_data %}leadsList(){% endblock %} + +{% block content %} +{{ page_header('Leads', action_label='Export CSV', action_onclick='exportCSV()', action_icon='download') }} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +{{ loading_state('Loading leads...') }} +{{ error_state('Error loading leads') }} + + +
+
+ + + + + + + + + + + + + + +
Business / DomainScoreTierIssuesContactActions
+
+
+ +{{ pagination_controls() }} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/prospecting/templates/prospecting/admin/prospect-detail.html b/app/modules/prospecting/templates/prospecting/admin/prospect-detail.html new file mode 100644 index 00000000..6eb1df2c --- /dev/null +++ b/app/modules/prospecting/templates/prospecting/admin/prospect-detail.html @@ -0,0 +1,280 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Prospect Detail{% endblock %} + +{% block alpine_data %}prospectDetail({{ prospect_id }}){% endblock %} + +{% block content %} +{{ loading_state('Loading prospect...') }} +{{ error_state('Error loading prospect') }} + +
+ +
+
+ + + +
+

+

+
+ +
+
+ +
+
+
+
+
+
+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+

Contact Info

+ +

No contacts found

+
+ + +
+

Score Breakdown

+ +

Not scored yet

+
+ + +
+

Technology

+
+ +
+
+ + +
+

Performance

+
+
+ Performance Score + +
+
+ Mobile Friendly + +
+
+ SEO Score + +
+
+
+
+ + +
+
+ +
+ +
+ +

No interactions yet

+
+
+ + +
+
+ +
+ +

No campaigns sent yet

+
+
+ + +
+
+
+

Log Interaction

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/prospecting/templates/prospecting/admin/prospects.html b/app/modules/prospecting/templates/prospecting/admin/prospects.html new file mode 100644 index 00000000..b9897977 --- /dev/null +++ b/app/modules/prospecting/templates/prospecting/admin/prospects.html @@ -0,0 +1,226 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_header, table_empty %} +{% from 'shared/macros/pagination.html' import pagination_controls %} + +{% block title %}Prospects{% endblock %} + +{% block alpine_data %}prospectsList(){% endblock %} + +{% block content %} +{{ page_header('Prospects', action_label='New Prospect', action_onclick='showCreateModal = true', action_icon='plus') }} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +{{ loading_state('Loading prospects...') }} +{{ error_state('Error loading prospects') }} + + +
+
+ + + + + + + + + + + + + + + +
Business / DomainChannelStatusScoreTierContactActions
+
+ + {{ table_empty('No prospects found') }} +
+ +{{ pagination_controls() }} + + +
+
+
+

New Prospect

+ +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/prospecting/templates/prospecting/admin/scan-jobs.html b/app/modules/prospecting/templates/prospecting/admin/scan-jobs.html new file mode 100644 index 00000000..f6314753 --- /dev/null +++ b/app/modules/prospecting/templates/prospecting/admin/scan-jobs.html @@ -0,0 +1,94 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/pagination.html' import pagination_controls %} + +{% block title %}Scan Jobs{% endblock %} + +{% block alpine_data %}scanJobs(){% endblock %} + +{% block content %} +{{ page_header('Scan Jobs') }} + + +
+ + + + + +
+ +{{ loading_state('Loading scan jobs...') }} +{{ error_state('Error loading scan jobs') }} + + +
+
+ + + + + + + + + + + + + + +
Job TypeStatusProgressStartedDurationResult
+
+
+ +{{ pagination_controls() }} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/prospecting/tests/__init__.py b/app/modules/prospecting/tests/__init__.py new file mode 100644 index 00000000..ec12b646 --- /dev/null +++ b/app/modules/prospecting/tests/__init__.py @@ -0,0 +1 @@ +# app/modules/prospecting/tests/__init__.py diff --git a/app/modules/prospecting/tests/conftest.py b/app/modules/prospecting/tests/conftest.py new file mode 100644 index 00000000..4e4a5422 --- /dev/null +++ b/app/modules/prospecting/tests/conftest.py @@ -0,0 +1,231 @@ +# app/modules/prospecting/tests/conftest.py +""" +Module-specific fixtures for prospecting tests. +Core fixtures (db, client, etc.) are inherited from the root conftest.py. +""" + +import json +import uuid + +import pytest + +from app.modules.prospecting.models import ( + CampaignTemplate, + Prospect, + ProspectContact, + ProspectInteraction, + ProspectPerformanceProfile, + ProspectScore, + ProspectTechProfile, +) + + +@pytest.fixture +def digital_prospect(db): + """Create a digital prospect with a domain.""" + prospect = Prospect( + channel="digital", + domain_name=f"test-{uuid.uuid4().hex[:8]}.lu", + status="active", + source="domain_scan", + has_website=True, + uses_https=True, + http_status_code=200, + country="LU", + ) + db.add(prospect) + db.commit() + db.refresh(prospect) + return prospect + + +@pytest.fixture +def offline_prospect(db): + """Create an offline prospect with business details.""" + prospect = Prospect( + channel="offline", + business_name=f"Test Business {uuid.uuid4().hex[:8]}", + status="pending", + source="networking_event", + city="Luxembourg", + postal_code="1234", + country="LU", + notes="Met at networking event", + tags=json.dumps(["networking", "no-website"]), + ) + db.add(prospect) + db.commit() + db.refresh(prospect) + return prospect + + +@pytest.fixture +def prospect_with_tech(db, digital_prospect): + """Create a digital prospect with tech profile.""" + tech = ProspectTechProfile( + prospect_id=digital_prospect.id, + cms="WordPress", + cms_version="5.9", + server="nginx", + js_framework="jQuery", + analytics="Google Analytics", + ecommerce_platform=None, + ) + db.add(tech) + db.commit() + db.refresh(digital_prospect) + return digital_prospect + + +@pytest.fixture +def prospect_with_performance(db, digital_prospect): + """Create a digital prospect with performance profile.""" + perf = ProspectPerformanceProfile( + prospect_id=digital_prospect.id, + performance_score=45, + accessibility_score=70, + seo_score=60, + first_contentful_paint_ms=2500, + largest_contentful_paint_ms=4200, + total_blocking_time_ms=350, + cumulative_layout_shift=0.15, + is_mobile_friendly=False, + ) + db.add(perf) + db.commit() + db.refresh(digital_prospect) + return digital_prospect + + +@pytest.fixture +def prospect_with_contacts(db, digital_prospect): + """Create a digital prospect with contacts.""" + contacts = [ + ProspectContact( + prospect_id=digital_prospect.id, + contact_type="email", + value="info@test.lu", + source_url="https://test.lu/contact", + is_primary=True, + ), + ProspectContact( + prospect_id=digital_prospect.id, + contact_type="phone", + value="+352 123 456", + source_url="https://test.lu/contact", + is_primary=True, + ), + ] + db.add_all(contacts) + db.commit() + db.refresh(digital_prospect) + return digital_prospect + + +@pytest.fixture +def prospect_with_score(db, digital_prospect): + """Create a digital prospect with a score.""" + score = ProspectScore( + prospect_id=digital_prospect.id, + score=72, + technical_health_score=25, + modernity_score=20, + business_value_score=20, + engagement_score=7, + reason_flags=json.dumps(["no_ssl", "slow"]), + score_breakdown=json.dumps({"no_ssl": 15, "slow": 10}), + lead_tier="top_priority", + ) + db.add(score) + db.commit() + db.refresh(digital_prospect) + return digital_prospect + + +@pytest.fixture +def prospect_full(db): + """Create a fully enriched digital prospect with all related data.""" + prospect = Prospect( + channel="digital", + domain_name=f"full-{uuid.uuid4().hex[:8]}.lu", + status="active", + source="domain_scan", + has_website=True, + uses_https=False, + http_status_code=200, + country="LU", + ) + db.add(prospect) + db.flush() + + tech = ProspectTechProfile( + prospect_id=prospect.id, + cms="Drupal", + cms_version="7.0", + server="Apache", + js_framework="jQuery", + ) + perf = ProspectPerformanceProfile( + prospect_id=prospect.id, + performance_score=25, + accessibility_score=50, + seo_score=40, + is_mobile_friendly=False, + ) + contact = ProspectContact( + prospect_id=prospect.id, + contact_type="email", + value="info@full-test.lu", + is_primary=True, + ) + score = ProspectScore( + prospect_id=prospect.id, + score=85, + technical_health_score=35, + modernity_score=20, + business_value_score=20, + engagement_score=10, + reason_flags=json.dumps(["no_ssl", "very_slow", "outdated_cms", "not_mobile_friendly"]), + lead_tier="top_priority", + ) + + db.add_all([tech, perf, contact, score]) + db.commit() + db.refresh(prospect) + return prospect + + +@pytest.fixture +def campaign_template(db): + """Create a campaign template.""" + template = CampaignTemplate( + name="Test Template", + lead_type="bad_website", + channel="email", + language="fr", + subject_template="Votre site {domain} a des problèmes", + body_template="Bonjour {business_name},\n\nVotre site {domain} a un score de {score}.\n\nIssues: {issues}", + is_active=True, + ) + db.add(template) + db.commit() + db.refresh(template) + return template + + +@pytest.fixture +def interaction(db, digital_prospect): + """Create a prospect interaction.""" + interaction = ProspectInteraction( + prospect_id=digital_prospect.id, + interaction_type="call", + subject="Initial contact", + notes="Discussed website needs", + outcome="positive", + next_action="Send proposal", + created_by_user_id=1, + ) + db.add(interaction) + db.commit() + db.refresh(interaction) + return interaction diff --git a/app/modules/prospecting/tests/unit/__init__.py b/app/modules/prospecting/tests/unit/__init__.py new file mode 100644 index 00000000..d5553544 --- /dev/null +++ b/app/modules/prospecting/tests/unit/__init__.py @@ -0,0 +1 @@ +# app/modules/prospecting/tests/unit/__init__.py diff --git a/app/modules/prospecting/tests/unit/test_campaign_service.py b/app/modules/prospecting/tests/unit/test_campaign_service.py new file mode 100644 index 00000000..710d7fcf --- /dev/null +++ b/app/modules/prospecting/tests/unit/test_campaign_service.py @@ -0,0 +1,107 @@ +# app/modules/prospecting/tests/unit/test_campaign_service.py +""" +Unit tests for CampaignService. +""" + +import pytest + +from app.modules.prospecting.exceptions import CampaignTemplateNotFoundException +from app.modules.prospecting.services.campaign_service import CampaignService + + +@pytest.mark.unit +@pytest.mark.prospecting +class TestCampaignService: + """Tests for CampaignService.""" + + def setup_method(self): + self.service = CampaignService() + + def test_create_template(self, db): + """Test creating a campaign template.""" + template = self.service.create_template(db, { + "name": "Test Campaign", + "lead_type": "bad_website", + "channel": "email", + "language": "fr", + "subject_template": "Subject for {domain}", + "body_template": "Hello {business_name}, your site {domain} has issues.", + }) + + assert template.id is not None + assert template.name == "Test Campaign" + assert template.lead_type == "bad_website" + + def test_get_templates(self, db, campaign_template): + """Test listing campaign templates.""" + templates = self.service.get_templates(db) + assert len(templates) >= 1 + + def test_get_templates_filter_lead_type(self, db, campaign_template): + """Test filtering templates by lead type.""" + templates = self.service.get_templates(db, lead_type="bad_website") + assert len(templates) >= 1 + + templates = self.service.get_templates(db, lead_type="no_website") + # campaign_template is bad_website, so this might be empty + assert isinstance(templates, list) + + def test_get_template_by_id(self, db, campaign_template): + """Test getting a template by ID.""" + result = self.service.get_template_by_id(db, campaign_template.id) + assert result.id == campaign_template.id + + def test_get_template_not_found(self, db): + """Test getting non-existent template raises exception.""" + with pytest.raises(CampaignTemplateNotFoundException): + self.service.get_template_by_id(db, 99999) + + def test_update_template(self, db, campaign_template): + """Test updating a template.""" + updated = self.service.update_template( + db, campaign_template.id, {"name": "Updated Campaign"} + ) + assert updated.name == "Updated Campaign" + + def test_delete_template(self, db, campaign_template): + """Test deleting a template.""" + tid = campaign_template.id + self.service.delete_template(db, tid) + + with pytest.raises(CampaignTemplateNotFoundException): + self.service.get_template_by_id(db, tid) + + def test_render_campaign(self, db, campaign_template, prospect_with_score): + """Test rendering a campaign with prospect data.""" + result = self.service.render_campaign( + db, campaign_template.id, prospect_with_score.id + ) + + assert "subject" in result + assert "body" in result + assert prospect_with_score.domain_name in result["subject"] + + def test_send_campaign(self, db, campaign_template, digital_prospect): + """Test sending a campaign to prospects.""" + sends = self.service.send_campaign( + db, + template_id=campaign_template.id, + prospect_ids=[digital_prospect.id], + sent_by_user_id=1, + ) + + assert len(sends) == 1 + assert sends[0].prospect_id == digital_prospect.id + assert sends[0].status.value == "sent" + + def test_get_sends(self, db, campaign_template, digital_prospect): + """Test getting campaign sends.""" + self.service.send_campaign( + db, + template_id=campaign_template.id, + prospect_ids=[digital_prospect.id], + sent_by_user_id=1, + ) + + sends = self.service.get_sends(db, prospect_id=digital_prospect.id) + assert len(sends) >= 1 diff --git a/app/modules/prospecting/tests/unit/test_enrichment_service.py b/app/modules/prospecting/tests/unit/test_enrichment_service.py new file mode 100644 index 00000000..8638667a --- /dev/null +++ b/app/modules/prospecting/tests/unit/test_enrichment_service.py @@ -0,0 +1,98 @@ +# app/modules/prospecting/tests/unit/test_enrichment_service.py +""" +Unit tests for EnrichmentService. + +Note: These tests mock external HTTP calls to avoid real network requests. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.modules.prospecting.services.enrichment_service import EnrichmentService + + +@pytest.mark.unit +@pytest.mark.prospecting +class TestEnrichmentService: + """Tests for EnrichmentService.""" + + def setup_method(self): + self.service = EnrichmentService() + + def test_check_http_success(self, db, digital_prospect): + """Test HTTP check with mocked successful response.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.url = f"https://{digital_prospect.domain_name}" + mock_response.headers = {} + + with patch("app.modules.prospecting.services.enrichment_service.requests.get", return_value=mock_response): + result = self.service.check_http(db, digital_prospect) + + assert result["has_website"] is True + assert result["uses_https"] is True + + def test_check_http_no_website(self, db, digital_prospect): + """Test HTTP check when website doesn't respond.""" + import requests as req + + with patch( + "app.modules.prospecting.services.enrichment_service.requests.get", + side_effect=req.exceptions.ConnectionError("Connection refused"), + ): + result = self.service.check_http(db, digital_prospect) + + assert result["has_website"] is False + + def test_check_http_no_domain(self, db, offline_prospect): + """Test HTTP check with no domain name.""" + result = self.service.check_http(db, offline_prospect) + assert result["has_website"] is False + assert result["error"] == "No domain name" + + def test_detect_cms_wordpress(self): + """Test CMS detection for WordPress.""" + html = '' + assert self.service._detect_cms(html) == "wordpress" + + def test_detect_cms_drupal(self): + """Test CMS detection for Drupal.""" + html = '' + assert self.service._detect_cms(html) == "drupal" + + def test_detect_cms_none(self): + """Test CMS detection when no CMS detected.""" + html = "Hello world" + assert self.service._detect_cms(html) is None + + def test_detect_js_framework_jquery(self): + """Test JS framework detection for jQuery.""" + html = '' + assert self.service._detect_js_framework(html) == "jquery" + + def test_detect_js_framework_react(self): + """Test JS framework detection for React.""" + html = '' + assert self.service._detect_js_framework(html) == "react" + + def test_detect_analytics_google(self): + """Test analytics detection for Google Analytics.""" + html = '' + result = self.service._detect_analytics(html) + assert result is not None + assert "google" in result + + def test_detect_analytics_none(self): + """Test analytics detection when no analytics detected.""" + html = "No analytics here" + assert self.service._detect_analytics(html) is None + + def test_service_instance_exists(self): + """Test that service singleton is importable.""" + from app.modules.prospecting.services.enrichment_service import ( + enrichment_service, + ) + + assert enrichment_service is not None + assert isinstance(enrichment_service, EnrichmentService) diff --git a/app/modules/prospecting/tests/unit/test_interaction_service.py b/app/modules/prospecting/tests/unit/test_interaction_service.py new file mode 100644 index 00000000..6f78cb30 --- /dev/null +++ b/app/modules/prospecting/tests/unit/test_interaction_service.py @@ -0,0 +1,77 @@ +# app/modules/prospecting/tests/unit/test_interaction_service.py +""" +Unit tests for InteractionService. +""" + +from datetime import date, timedelta + +import pytest + +from app.modules.prospecting.services.interaction_service import InteractionService + + +@pytest.mark.unit +@pytest.mark.prospecting +class TestInteractionService: + """Tests for InteractionService.""" + + def setup_method(self): + self.service = InteractionService() + + def test_create_interaction(self, db, digital_prospect): + """Test creating an interaction.""" + interaction = self.service.create( + db, + prospect_id=digital_prospect.id, + user_id=1, + data={ + "interaction_type": "call", + "subject": "Initial call", + "notes": "Discussed website needs", + "outcome": "positive", + }, + ) + + assert interaction.id is not None + assert interaction.interaction_type.value == "call" + assert interaction.outcome.value == "positive" + + def test_create_interaction_with_follow_up(self, db, digital_prospect): + """Test creating an interaction with follow-up action.""" + follow_up_date = date.today() + timedelta(days=7) + interaction = self.service.create( + db, + prospect_id=digital_prospect.id, + user_id=1, + data={ + "interaction_type": "meeting", + "subject": "Office visit", + "next_action": "Send proposal", + "next_action_date": str(follow_up_date), + }, + ) + + assert interaction.next_action == "Send proposal" + + def test_get_for_prospect(self, db, digital_prospect, interaction): + """Test getting interactions for a prospect.""" + interactions = self.service.get_for_prospect(db, digital_prospect.id) + assert len(interactions) >= 1 + assert interactions[0].prospect_id == digital_prospect.id + + def test_get_upcoming_actions(self, db, digital_prospect): + """Test getting upcoming follow-up actions.""" + follow_up_date = date.today() + timedelta(days=3) + self.service.create( + db, + prospect_id=digital_prospect.id, + user_id=1, + data={ + "interaction_type": "call", + "next_action": "Follow up", + "next_action_date": str(follow_up_date), + }, + ) + + upcoming = self.service.get_upcoming_actions(db) + assert len(upcoming) >= 1 diff --git a/app/modules/prospecting/tests/unit/test_lead_service.py b/app/modules/prospecting/tests/unit/test_lead_service.py new file mode 100644 index 00000000..ba6a922c --- /dev/null +++ b/app/modules/prospecting/tests/unit/test_lead_service.py @@ -0,0 +1,59 @@ +# app/modules/prospecting/tests/unit/test_lead_service.py +""" +Unit tests for LeadService. +""" + +import pytest + +from app.modules.prospecting.services.lead_service import LeadService + + +@pytest.mark.unit +@pytest.mark.prospecting +class TestLeadService: + """Tests for LeadService.""" + + def setup_method(self): + self.service = LeadService() + + def test_get_leads_empty(self, db): + """Test getting leads when none exist.""" + leads, total = self.service.get_leads(db) + assert total == 0 + assert leads == [] + + def test_get_leads_with_scored_prospect(self, db, prospect_with_score): + """Test getting leads returns scored prospects.""" + leads, total = self.service.get_leads(db) + assert total >= 1 + + def test_get_leads_filter_min_score(self, db, prospect_with_score): + """Test filtering leads by minimum score.""" + leads, total = self.service.get_leads(db, min_score=70) + assert total >= 1 # prospect_with_score has score 72 + + leads, total = self.service.get_leads(db, min_score=90) + assert total == 0 # No prospect has score >= 90 + + def test_get_leads_filter_lead_tier(self, db, prospect_with_score): + """Test filtering leads by tier.""" + leads, total = self.service.get_leads(db, lead_tier="top_priority") + assert total >= 1 # prospect_with_score is top_priority (72) + + def test_get_top_priority(self, db, prospect_with_score): + """Test getting top priority leads.""" + leads = self.service.get_top_priority(db) + assert len(leads) >= 1 + + def test_get_quick_wins(self, db, prospect_with_score): + """Test getting quick win leads (score 50-69).""" + # prospect_with_score has score 72, not a quick win + leads = self.service.get_quick_wins(db) + # May be empty if no prospects in 50-69 range + assert isinstance(leads, list) + + def test_export_csv(self, db, prospect_with_score): + """Test CSV export returns valid CSV content.""" + csv_content = self.service.export_csv(db) + assert isinstance(csv_content, str) + assert "domain" in csv_content.lower() or "score" in csv_content.lower() diff --git a/app/modules/prospecting/tests/unit/test_prospect_service.py b/app/modules/prospecting/tests/unit/test_prospect_service.py new file mode 100644 index 00000000..3ab73573 --- /dev/null +++ b/app/modules/prospecting/tests/unit/test_prospect_service.py @@ -0,0 +1,149 @@ +# app/modules/prospecting/tests/unit/test_prospect_service.py +""" +Unit tests for ProspectService. +""" + +import pytest + +from app.modules.prospecting.exceptions import ProspectNotFoundException +from app.modules.prospecting.services.prospect_service import ProspectService + + +@pytest.mark.unit +@pytest.mark.prospecting +class TestProspectService: + """Tests for ProspectService.""" + + def setup_method(self): + self.service = ProspectService() + + def test_create_digital_prospect(self, db): + """Test creating a digital prospect.""" + prospect = self.service.create( + db, + {"channel": "digital", "domain_name": "example.lu", "source": "domain_scan"}, + ) + + assert prospect.id is not None + assert prospect.channel.value == "digital" + assert prospect.domain_name == "example.lu" + assert prospect.status.value == "pending" + + def test_create_offline_prospect(self, db): + """Test creating an offline prospect.""" + prospect = self.service.create( + db, + { + "channel": "offline", + "business_name": "Test Café", + "source": "street", + "city": "Luxembourg", + }, + ) + + assert prospect.id is not None + assert prospect.channel.value == "offline" + assert prospect.business_name == "Test Café" + assert prospect.city == "Luxembourg" + + def test_create_digital_prospect_with_contacts(self, db): + """Test creating a digital prospect with inline contacts.""" + prospect = self.service.create( + db, + { + "channel": "digital", + "domain_name": "with-contacts.lu", + "contacts": [ + {"contact_type": "email", "value": "info@with-contacts.lu"}, + ], + }, + ) + + assert prospect.id is not None + assert len(prospect.contacts) == 1 + assert prospect.contacts[0].value == "info@with-contacts.lu" + + def test_get_by_id(self, db, digital_prospect): + """Test getting a prospect by ID.""" + result = self.service.get_by_id(db, digital_prospect.id) + assert result.id == digital_prospect.id + assert result.domain_name == digital_prospect.domain_name + + def test_get_by_id_not_found(self, db): + """Test getting non-existent prospect raises exception.""" + with pytest.raises(ProspectNotFoundException): + self.service.get_by_id(db, 99999) + + def test_get_by_domain(self, db, digital_prospect): + """Test getting a prospect by domain name.""" + result = self.service.get_by_domain(db, digital_prospect.domain_name) + assert result is not None + assert result.id == digital_prospect.id + + def test_get_all_with_pagination(self, db, digital_prospect, offline_prospect): + """Test listing prospects with pagination.""" + prospects, total = self.service.get_all(db, page=1, per_page=10) + assert total >= 2 + assert len(prospects) >= 2 + + def test_get_all_filter_by_channel(self, db, digital_prospect, offline_prospect): + """Test filtering prospects by channel.""" + digital, count = self.service.get_all(db, channel="digital") + assert count >= 1 + assert all(p.channel.value == "digital" for p in digital) + + def test_get_all_filter_by_status(self, db, digital_prospect): + """Test filtering prospects by status.""" + active, count = self.service.get_all(db, status="active") + assert count >= 1 + assert all(p.status.value == "active" for p in active) + + def test_get_all_search(self, db, digital_prospect): + """Test searching prospects by domain or business name.""" + domain = digital_prospect.domain_name + results, count = self.service.get_all(db, search=domain[:8]) + assert count >= 1 + + def test_update_prospect(self, db, digital_prospect): + """Test updating a prospect.""" + updated = self.service.update( + db, digital_prospect.id, {"notes": "Updated notes", "status": "contacted"} + ) + assert updated.notes == "Updated notes" + assert updated.status.value == "contacted" + + def test_delete_prospect(self, db, digital_prospect): + """Test deleting a prospect.""" + pid = digital_prospect.id + self.service.delete(db, pid) + + with pytest.raises(ProspectNotFoundException): + self.service.get_by_id(db, pid) + + def test_create_bulk(self, db): + """Test bulk creating prospects from domain list.""" + domains = ["bulk1.lu", "bulk2.lu", "bulk3.lu"] + created, skipped = self.service.create_bulk(db, domains, source="csv_import") + assert created == 3 + assert skipped == 0 + + def test_create_bulk_skips_duplicates(self, db, digital_prospect): + """Test bulk create skips existing domains.""" + domains = [digital_prospect.domain_name, "new-domain.lu"] + created, skipped = self.service.create_bulk(db, domains, source="csv_import") + assert created == 1 + assert skipped == 1 + + def test_count_by_status(self, db, digital_prospect, offline_prospect): + """Test counting prospects by status.""" + counts = self.service.count_by_status(db) + assert isinstance(counts, dict) + assert counts.get("active", 0) >= 1 + assert counts.get("pending", 0) >= 1 + + def test_count_by_channel(self, db, digital_prospect, offline_prospect): + """Test counting prospects by channel.""" + counts = self.service.count_by_channel(db) + assert isinstance(counts, dict) + assert counts.get("digital", 0) >= 1 + assert counts.get("offline", 0) >= 1 diff --git a/app/modules/prospecting/tests/unit/test_scoring_service.py b/app/modules/prospecting/tests/unit/test_scoring_service.py new file mode 100644 index 00000000..76ac2f6b --- /dev/null +++ b/app/modules/prospecting/tests/unit/test_scoring_service.py @@ -0,0 +1,89 @@ +# app/modules/prospecting/tests/unit/test_scoring_service.py +""" +Unit tests for ScoringService. +""" + +import json + +import pytest + +from app.modules.prospecting.services.scoring_service import ScoringService + + +@pytest.mark.unit +@pytest.mark.prospecting +class TestScoringService: + """Tests for ScoringService.""" + + def setup_method(self): + self.service = ScoringService() + + def test_score_digital_no_ssl(self, db, digital_prospect): + """Test scoring a digital prospect without SSL.""" + digital_prospect.uses_https = False + db.commit() + db.refresh(digital_prospect) + + score = self.service.compute_score(db, digital_prospect) + assert score.score > 0 + flags = json.loads(score.reason_flags) if score.reason_flags else [] + assert "no_ssl" in flags + + def test_score_digital_slow_site(self, db, prospect_with_performance): + """Test scoring a prospect with slow performance.""" + score = self.service.compute_score(db, prospect_with_performance) + assert score.technical_health_score > 0 + flags = json.loads(score.reason_flags) if score.reason_flags else [] + assert "slow" in flags or "very_slow" in flags + + def test_score_digital_outdated_cms(self, db, prospect_with_tech): + """Test scoring a prospect with outdated CMS (jQuery counts as legacy).""" + # WordPress is not "outdated" but jQuery is legacy + score = self.service.compute_score(db, prospect_with_tech) + flags = json.loads(score.reason_flags) if score.reason_flags else [] + assert "legacy_js" in flags + + def test_score_digital_full_prospect(self, db, prospect_full): + """Test scoring a fully enriched prospect (no SSL, slow, outdated CMS).""" + score = self.service.compute_score(db, prospect_full) + assert score.score >= 70 + assert score.lead_tier == "top_priority" + + def test_score_offline_no_website(self, db, offline_prospect): + """Test scoring an offline prospect with no website (base score 70).""" + score = self.service.compute_score(db, offline_prospect) + assert score.score >= 70 + assert score.lead_tier == "top_priority" + flags = json.loads(score.reason_flags) if score.reason_flags else [] + assert "no_website" in flags + + def test_score_components_max(self, db, prospect_full): + """Test that score components don't exceed their maximums.""" + score = self.service.compute_score(db, prospect_full) + assert score.technical_health_score <= 40 + assert score.modernity_score <= 25 + assert score.business_value_score <= 25 + assert score.engagement_score <= 10 + assert score.score <= 100 + + def test_lead_tier_assignment(self, db, digital_prospect, offline_prospect): + """Test that lead tiers are correctly assigned based on score.""" + # Digital prospect with website + SSL should be low score + score1 = self.service.compute_score(db, digital_prospect) + assert score1.lead_tier in ("top_priority", "quick_win", "strategic", "low_priority") + + # Offline prospect with no website should be top_priority + score2 = self.service.compute_score(db, offline_prospect) + assert score2.lead_tier == "top_priority" + + def test_compute_score_updates_existing(self, db, prospect_with_score): + """Test that recomputing a score updates the existing record.""" + old_score_id = prospect_with_score.score.id + score = self.service.compute_score(db, prospect_with_score) + # Should update the same record + assert score.id == old_score_id + + def test_compute_all(self, db, digital_prospect, offline_prospect): + """Test batch score computation.""" + count = self.service.compute_all(db, limit=100) + assert count >= 2 diff --git a/app/modules/prospecting/tests/unit/test_stats_service.py b/app/modules/prospecting/tests/unit/test_stats_service.py new file mode 100644 index 00000000..cc99a60d --- /dev/null +++ b/app/modules/prospecting/tests/unit/test_stats_service.py @@ -0,0 +1,36 @@ +# app/modules/prospecting/tests/unit/test_stats_service.py +""" +Unit tests for StatsService. +""" + +import pytest + +from app.modules.prospecting.services.stats_service import StatsService + + +@pytest.mark.unit +@pytest.mark.prospecting +class TestStatsService: + """Tests for StatsService.""" + + def setup_method(self): + self.service = StatsService() + + def test_get_overview_empty(self, db): + """Test overview stats with no data.""" + stats = self.service.get_overview(db) + assert "total_prospects" in stats + assert stats["total_prospects"] == 0 + + def test_get_overview_with_data(self, db, digital_prospect, offline_prospect): + """Test overview stats with data.""" + stats = self.service.get_overview(db) + assert stats["total_prospects"] >= 2 + assert stats["digital_count"] >= 1 + assert stats["offline_count"] >= 1 + + def test_get_scan_jobs_empty(self, db): + """Test getting scan jobs when none exist.""" + jobs, total = self.service.get_scan_jobs(db) + assert total == 0 + assert jobs == [] diff --git a/pyproject.toml b/pyproject.toml index 7ec5e5dd..bcccf07e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,7 @@ testpaths = [ "app/modules/dev_tools/tests", "app/modules/monitoring/tests", "app/modules/analytics/tests", + "app/modules/prospecting/tests", ] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] @@ -221,6 +222,7 @@ markers = [ "cart: marks tests related to shopping cart module", "dev_tools: marks tests related to developer tools module", "analytics: marks tests related to analytics module", + "prospecting: marks tests related to prospecting and lead generation module", "inventory_module: marks tests related to inventory module", # Component markers "service: marks tests for service layer", diff --git a/scripts/validate/base_validator.py b/scripts/validate/base_validator.py index 26f29d17..c1f80793 100755 --- a/scripts/validate/base_validator.py +++ b/scripts/validate/base_validator.py @@ -64,6 +64,7 @@ class BaseValidator(ABC): ".pytest_cache", ".mypy_cache", "dist", "build", "*.egg-info", "migrations", "alembic/versions", ".tox", "htmlcov", "site", # mkdocs build output + "scripts/security-audit", # needs revamping ] # Regex for noqa comments. Supports both ruff-compatible (SEC001) and