feat(prospecting): add complete prospecting module for lead discovery and scoring
Some checks failed
CI / pytest (push) Failing after 48m31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 11s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 28s

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 00:59:47 +01:00
parent a709adaee8
commit 6d6eba75bf
79 changed files with 7551 additions and 0 deletions

3
.gitignore vendored
View File

@@ -190,3 +190,6 @@ static/shared/css/tailwind.css
# Export files
orion_letzshop_export_*.csv
exports/
# Security audit (needs revamping)
scripts/security-audit/

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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}`.

View File

@@ -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.

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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}"

View File

@@ -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")

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View File

@@ -0,0 +1 @@
# app/modules/prospecting/routes/__init__.py

View File

@@ -0,0 +1 @@
# app/modules/prospecting/routes/api/__init__.py

View File

@@ -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"])

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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"},
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -0,0 +1 @@
# app/modules/prospecting/routes/pages/__init__.py

View File

@@ -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),
)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
# app/modules/prospecting/services/__init__.py

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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,
};
},
};
}

View File

@@ -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,
};
},
};
}

View File

@@ -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, ' ');
},
};
}

View File

@@ -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';
},
};
}

View File

@@ -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;
},
};
}

View File

@@ -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';
},
};
}

View File

@@ -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';
},
};
}

View File

@@ -0,0 +1 @@
# app/modules/prospecting/tasks/__init__.py

View File

@@ -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

View File

@@ -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') }}
<!-- Filter by Lead Type -->
<div class="mb-6 flex flex-wrap gap-2">
<button @click="filterLeadType = ''" :class="!filterLeadType ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="px-3 py-1.5 text-sm rounded-lg">All</button>
<template x-for="lt in leadTypes" :key="lt.value">
<button @click="filterLeadType = lt.value"
:class="filterLeadType === lt.value ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="px-3 py-1.5 text-sm rounded-lg"
x-text="lt.label"></button>
</template>
</div>
{{ loading_state('Loading templates...') }}
{{ error_state('Error loading templates') }}
<!-- Templates Grid -->
<div x-show="!loading && !error" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<template x-for="tpl in filteredTemplates()" :key="tpl.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="tpl.name"></h3>
<div class="flex items-center space-x-2 mt-1">
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
x-text="tpl.lead_type.replace('_', ' ')"></span>
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="tpl.channel"></span>
<span class="text-xs text-gray-400" x-text="tpl.language.toUpperCase()"></span>
</div>
</div>
<span class="w-2 h-2 rounded-full" :class="tpl.is_active ? 'bg-green-500' : 'bg-gray-400'"></span>
</div>
<p x-show="tpl.subject_template" class="text-xs text-gray-500 mb-2 truncate" x-text="'Subject: ' + tpl.subject_template"></p>
<p class="text-xs text-gray-400 line-clamp-2" x-text="tpl.body_template"></p>
<div class="flex justify-end mt-3 space-x-2">
<button @click="editTemplate(tpl)" class="text-xs text-purple-600 hover:text-purple-900 dark:text-purple-400">Edit</button>
<button @click="deleteTemplate(tpl.id)" class="text-xs text-red-600 hover:text-red-900 dark:text-red-400">Delete</button>
</div>
</div>
</template>
</div>
<!-- Create/Edit Template Modal -->
<div x-show="showCreateModal || showEditModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showCreateModal = false; showEditModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl max-h-[90vh] overflow-y-auto"
@keydown.escape.window="showCreateModal = false; showEditModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="showEditModal ? 'Edit Template' : 'New Template'"></h3>
<button @click="showCreateModal = false; showEditModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label>
<input type="text" x-model="templateForm.name"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Lead Type</label>
<select x-model="templateForm.lead_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<template x-for="lt in leadTypes" :key="lt.value">
<option :value="lt.value" x-text="lt.label"></option>
</template>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
<select x-model="templateForm.channel"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="email">Email</option>
<option value="letter">Letter</option>
<option value="phone_script">Phone Script</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Language</label>
<select x-model="templateForm.language"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="fr">French</option>
<option value="de">German</option>
<option value="en">English</option>
<option value="lb">Luxembourgish</option>
</select>
</div>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label>
<input type="text" x-model="templateForm.subject_template"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Body</label>
<textarea x-model="templateForm.body_template" rows="10"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 font-mono"></textarea>
<div class="mt-1 flex flex-wrap gap-1">
<span class="text-xs text-gray-400">Placeholders:</span>
<template x-for="ph in placeholders" :key="ph">
<button @click="insertPlaceholder(ph)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="ph"></button>
</template>
</div>
</div>
<div class="flex items-center">
<input type="checkbox" x-model="templateForm.is_active" id="tpl-active"
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<label for="tpl-active" class="ml-2 text-sm text-gray-600 dark:text-gray-400">Active</label>
</div>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showCreateModal = false; showEditModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<button @click="saveTemplate()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Save
</button>
</footer>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('prospecting_static', path='admin/js/campaigns.js') }}"></script>
{% endblock %}

View File

@@ -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') }}
<div class="max-w-lg mx-auto">
<!-- Success Message -->
<div x-show="saved" x-transition
class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg dark:bg-green-900 dark:text-green-300">
<span x-html="$icon('check-circle', 'w-5 h-5 inline mr-2')"></span>
<span x-text="'Prospect saved: ' + lastSaved"></span>
</div>
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800 space-y-5">
<!-- Business Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Business Name *</label>
<input type="text" x-model="form.business_name" required autofocus
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300 py-3">
</div>
<!-- Phone + Email Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
<input type="tel" x-model="form.phone" placeholder="+352..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input type="email" x-model="form.email" placeholder="contact@..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
</div>
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address</label>
<input type="text" x-model="form.address"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
</div>
<!-- City + Postal Code -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
<input type="text" x-model="form.city" placeholder="Luxembourg"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code</label>
<input type="text" x-model="form.postal_code"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
</div>
</div>
<!-- Source -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Source</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<template x-for="src in sources" :key="src.value">
<button @click="form.source = src.value"
:class="form.source === src.value ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600'"
class="px-3 py-2 text-sm font-medium rounded-lg border"
x-text="src.label"></button>
</template>
</div>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label>
<div class="flex flex-wrap gap-2">
<template x-for="tag in availableTags" :key="tag">
<button @click="toggleTag(tag)"
:class="form.tags.includes(tag) ? 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-400'"
class="px-3 py-1.5 text-xs font-medium rounded-full border"
x-text="tag"></button>
</template>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
<textarea x-model="form.notes" rows="3" placeholder="Quick notes..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"></textarea>
</div>
<!-- Location -->
<div class="flex items-center space-x-3">
<button @click="getLocation()"
:disabled="gettingLocation"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700 disabled:opacity-50">
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2')"></span>
<span x-text="gettingLocation ? 'Getting location...' : (form.location_lat ? 'Location saved' : 'Get Location')"></span>
</button>
<span x-show="form.location_lat" class="text-xs text-green-600">
<span x-text="form.location_lat?.toFixed(4)"></span>, <span x-text="form.location_lng?.toFixed(4)"></span>
</span>
</div>
<!-- Submit -->
<button @click="submitCapture()"
:disabled="submitting || !form.business_name"
class="w-full px-6 py-4 text-lg font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 active:bg-purple-800">
<span x-show="!submitting">Save & Capture Next</span>
<span x-show="submitting">Saving...</span>
</button>
</div>
<!-- Recent Captures -->
<div x-show="recentCaptures.length > 0" class="mt-6">
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">
Recent Captures (<span x-text="recentCaptures.length"></span>)
</h3>
<div class="space-y-2">
<template x-for="cap in recentCaptures" :key="cap.id">
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm dark:bg-gray-800">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cap.business_name"></span>
<span class="text-xs text-gray-500" x-text="cap.city || cap.source"></span>
</div>
</template>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('prospecting_static', path='admin/js/capture.js') }}"></script>
{% endblock %}

View File

@@ -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') }}
<div x-show="!loading && !error" class="space-y-6">
<!-- KPI Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('globe', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Prospects</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_prospects || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('target', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Top Priority</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.top_priority || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('chart-bar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Avg Score</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.avg_score ? stats.avg_score.toFixed(1) : '—'"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('device-mobile', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Offline Leads</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.offline_count || 0"></p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex flex-wrap gap-3 mb-6">
<a href="/admin/prospecting/capture"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('device-mobile', 'w-4 h-4 mr-2')"></span>
Quick Capture
</a>
<button @click="runBatchScan()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
<span x-html="$icon('radar', 'w-4 h-4 mr-2')"></span>
Run Batch Scan
</button>
<button @click="showImportModal = true"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Import Domains
</button>
</div>
<!-- Leads by Tier -->
<div class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Leads by Tier</h3>
<div class="space-y-3">
<template x-for="tier in ['top_priority', 'quick_win', 'strategic', 'low_priority']" :key="tier">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="tier.replace('_', ' ')"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="tierBadgeClass(tier)"
x-text="stats.leads_by_tier?.[tier] || 0"></span>
</div>
</template>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">By Channel</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Digital (Domain Scan)</span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="stats.digital_count || 0"></span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Offline (Captured)</span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="stats.offline_count || 0"></span>
</div>
</div>
</div>
</div>
<!-- Common Issues -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Common Issues</h3>
<div class="space-y-2">
<template x-for="issue in stats.common_issues || []" :key="issue.flag">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="issueLabel(issue.flag)"></span>
<div class="flex items-center">
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700 mr-2">
<div class="h-2 bg-red-500 rounded-full"
:style="'width: ' + (issue.count / (stats.total_prospects || 1) * 100) + '%'"></div>
</div>
<span class="text-xs text-gray-500" x-text="issue.count"></span>
</div>
</div>
</template>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('prospecting_static', path='admin/js/dashboard.js') }}"></script>
{% endblock %}

View File

@@ -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') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-5">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Min Score</label>
<input type="number" x-model.number="minScore" @change="loadLeads()" min="0" max="100"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label>
<select x-model="filterTier" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="top_priority">Top Priority</option>
<option value="quick_win">Quick Win</option>
<option value="strategic">Strategic</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
<select x-model="filterChannel" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="digital">Digital</option>
<option value="offline">Offline</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Issue</label>
<select x-model="filterIssue" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Any</option>
<option value="no_ssl">No SSL</option>
<option value="very_slow">Very Slow</option>
<option value="not_mobile_friendly">Not Mobile Friendly</option>
<option value="outdated_cms">Outdated CMS</option>
<option value="no_website">No Website</option>
<option value="uses_gmail">Uses Gmail</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Has Email</label>
<select x-model="filterHasEmail" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Any</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
</div>
{{ loading_state('Loading leads...') }}
{{ error_state('Error loading leads') }}
<!-- Leads Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business / Domain</th>
<th class="px-4 py-3">Score</th>
<th class="px-4 py-3">Tier</th>
<th class="px-4 py-3">Issues</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="lead in leads" :key="lead.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<p class="font-semibold text-sm" x-text="lead.business_name || lead.domain_name"></p>
<p class="text-xs text-gray-500" x-show="lead.domain_name && lead.business_name" x-text="lead.domain_name"></p>
</td>
<td class="px-4 py-3">
<span class="text-lg font-bold" :class="scoreColor(lead.score)"
x-text="lead.score"></span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="tierBadgeClass(lead.lead_tier)"
x-text="lead.lead_tier?.replace('_', ' ')"></span>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<template x-for="flag in (lead.reason_flags || []).slice(0, 3)" :key="flag">
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded dark:bg-red-900 dark:text-red-300"
x-text="flag.replace('_', ' ')"></span>
</template>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="lead.primary_email || lead.primary_phone || '—'" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm space-x-2">
<a :href="'/admin/prospecting/prospects/' + lead.id"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400">View</a>
<button @click="sendCampaign(lead)"
class="text-green-600 hover:text-green-900 dark:text-green-400">Campaign</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('prospecting_static', path='admin/js/leads.js') }}"></script>
{% endblock %}

View File

@@ -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') }}
<div x-show="!loading && !error && prospect" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/admin/prospecting/prospects"
class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a>
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
x-text="prospect.business_name || prospect.domain_name"></h2>
<p class="text-sm text-gray-500" x-show="prospect.domain_name && prospect.business_name"
x-text="prospect.domain_name"></p>
</div>
<span class="px-3 py-1 text-xs font-semibold rounded-full"
:class="prospect.channel === 'digital' ? 'text-blue-700 bg-blue-100' : 'text-purple-700 bg-purple-100'"
x-text="prospect.channel"></span>
</div>
<div class="flex items-center space-x-3">
<!-- Score Badge -->
<div x-show="prospect.score" class="text-center">
<div class="text-3xl font-bold" :class="scoreColor(prospect.score?.score)"
x-text="prospect.score?.score"></div>
<div class="text-xs text-gray-500 uppercase"
x-text="prospect.score?.lead_tier?.replace('_', ' ')"></div>
</div>
</div>
</div>
<!-- Status Bar -->
<div class="flex items-center space-x-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<label class="text-sm text-gray-600 dark:text-gray-400">Status:</label>
<select x-model="prospect.status" @change="updateStatus()"
class="text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="contacted">Contacted</option>
<option value="converted">Converted</option>
<option value="inactive">Inactive</option>
</select>
<button @click="runEnrichment()" x-show="prospect.channel === 'digital'"
class="ml-auto px-3 py-1 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700">
<span x-html="$icon('radar', 'w-4 h-4 inline mr-1')"></span>
Run Scan
</button>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-8">
<template x-for="tab in tabs" :key="tab.id">
<button @click="activeTab = tab.id"
:class="activeTab === tab.id ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
x-text="tab.label"></button>
</template>
</nav>
</div>
<!-- Tab: Overview -->
<div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2">
<!-- Contact Info -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3>
<template x-for="c in prospect.contacts || []" :key="c.id">
<div class="flex items-center justify-between py-2 border-b dark:border-gray-700 last:border-0">
<span class="text-xs text-gray-500 uppercase" x-text="c.contact_type"></span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="c.value"></span>
</div>
</template>
<p x-show="!prospect.contacts?.length" class="text-sm text-gray-400">No contacts found</p>
</div>
<!-- Score Breakdown -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Score Breakdown</h3>
<template x-if="prospect.score">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Technical Health</span>
<span class="font-semibold" x-text="prospect.score.technical_health_score + '/40'"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Modernity</span>
<span class="font-semibold" x-text="prospect.score.modernity_score + '/25'"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Business Value</span>
<span class="font-semibold" x-text="prospect.score.business_value_score + '/25'"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Engagement</span>
<span class="font-semibold" x-text="prospect.score.engagement_score + '/10'"></span>
</div>
<div class="pt-3 border-t dark:border-gray-700">
<h4 class="text-xs font-semibold text-gray-500 uppercase mb-2">Issues</h4>
<div class="flex flex-wrap gap-2">
<template x-for="flag in prospect.score.reason_flags || []" :key="flag">
<span class="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full dark:bg-red-900 dark:text-red-300"
x-text="flag.replace('_', ' ')"></span>
</template>
</div>
</div>
</div>
</template>
<p x-show="!prospect.score" class="text-sm text-gray-400">Not scored yet</p>
</div>
<!-- Tech Profile Summary -->
<div x-show="prospect.tech_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Technology</h3>
<div class="space-y-2 text-sm">
<template x-for="[key, val] in techProfileEntries()" :key="key">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400 capitalize" x-text="key.replace('_', ' ')"></span>
<span class="text-gray-700 dark:text-gray-300" x-text="val"></span>
</div>
</template>
</div>
</div>
<!-- Performance Summary -->
<div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Performance</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Performance Score</span>
<span class="font-semibold" :class="scoreColor(prospect.performance_profile?.performance_score)"
x-text="prospect.performance_profile?.performance_score ?? '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Mobile Friendly</span>
<span x-text="prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'"
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600' : 'text-red-600'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">SEO Score</span>
<span x-text="prospect.performance_profile?.seo_score ?? '—'"></span>
</div>
</div>
</div>
</div>
<!-- Tab: Interactions -->
<div x-show="activeTab === 'interactions'" class="space-y-4">
<div class="flex justify-end">
<button @click="showInteractionModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span>
Log Interaction
</button>
</div>
<!-- Timeline -->
<div class="space-y-4">
<template x-for="interaction in interactions" :key="interaction.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800 border-l-4"
:class="interaction.outcome === 'positive' ? 'border-green-500' : interaction.outcome === 'negative' ? 'border-red-500' : 'border-gray-300'">
<div class="flex items-center justify-between mb-2">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
x-text="interaction.interaction_type.replace('_', ' ')"></span>
<span class="text-xs text-gray-500" x-text="new Date(interaction.created_at).toLocaleDateString()"></span>
</div>
<p x-show="interaction.subject" class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="interaction.subject"></p>
<p x-show="interaction.notes" class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="interaction.notes"></p>
<p x-show="interaction.next_action" class="text-xs text-purple-600 mt-2">
Next: <span x-text="interaction.next_action"></span>
<span x-show="interaction.next_action_date" x-text="' — ' + interaction.next_action_date"></span>
</p>
</div>
</template>
<p x-show="interactions.length === 0" class="text-sm text-gray-400 text-center py-8">No interactions yet</p>
</div>
</div>
<!-- Tab: Campaigns -->
<div x-show="activeTab === 'campaigns'" class="space-y-4">
<div class="flex justify-end">
<button @click="showSendCampaignModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
<span x-html="$icon('mail', 'w-4 h-4 inline mr-1')"></span>
Send Campaign
</button>
</div>
<template x-for="send in campaignSends" :key="send.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="send.rendered_subject || 'No subject'"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="send.status === 'sent' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
x-text="send.status"></span>
</div>
<p class="text-xs text-gray-500 mt-1" x-text="send.sent_at ? new Date(send.sent_at).toLocaleString() : 'Draft'"></p>
</div>
</template>
<p x-show="campaignSends.length === 0" class="text-sm text-gray-400 text-center py-8">No campaigns sent yet</p>
</div>
</div>
<!-- Interaction Modal -->
<div x-show="showInteractionModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showInteractionModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showInteractionModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Log Interaction</h3>
<button @click="showInteractionModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label>
<select x-model="newInteraction.interaction_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="note">Note</option>
<option value="call">Phone Call</option>
<option value="email_sent">Email Sent</option>
<option value="email_received">Email Received</option>
<option value="meeting">Meeting</option>
<option value="visit">Visit</option>
<option value="proposal_sent">Proposal Sent</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label>
<input type="text" x-model="newInteraction.subject"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label>
<textarea x-model="newInteraction.notes" rows="3"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Outcome</label>
<select x-model="newInteraction.outcome"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Not specified</option>
<option value="positive">Positive</option>
<option value="neutral">Neutral</option>
<option value="negative">Negative</option>
<option value="no_answer">No Answer</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Next Action</label>
<input type="text" x-model="newInteraction.next_action" placeholder="Follow up by..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showInteractionModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<button @click="createInteraction()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Save
</button>
</footer>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('prospecting_static', path='admin/js/prospect-detail.js') }}"></script>
{% endblock %}

View File

@@ -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') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label>
<input type="text" x-model="search" @input.debounce.300ms="loadProspects()"
placeholder="Domain or business name..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
<select x-model="filterChannel" @change="loadProspects()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="digital">Digital</option>
<option value="offline">Offline</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
<select x-model="filterStatus" @change="loadProspects()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="contacted">Contacted</option>
<option value="converted">Converted</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label>
<select x-model="filterTier" @change="loadProspects()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="top_priority">Top Priority</option>
<option value="quick_win">Quick Win</option>
<option value="strategic">Strategic</option>
<option value="low_priority">Low Priority</option>
</select>
</div>
</div>
</div>
{{ loading_state('Loading prospects...') }}
{{ error_state('Error loading prospects') }}
<!-- Prospects Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business / Domain</th>
<th class="px-4 py-3">Channel</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Score</th>
<th class="px-4 py-3">Tier</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="p in prospects" :key="p.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="p.business_name || p.domain_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="p.domain_name && p.business_name" x-text="p.domain_name"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="p.channel === 'digital' ? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700' : 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'"
x-text="p.channel"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(p.status)"
x-text="p.status"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="p.score?.score ?? '—'" class="font-semibold"
:class="scoreColor(p.score?.score)"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-show="p.score?.lead_tier"
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="tierBadgeClass(p.score?.lead_tier)"
x-text="p.score?.lead_tier?.replace('_', ' ')"></span>
<span x-show="!p.score?.lead_tier" class="text-gray-400"></span>
</td>
<td class="px-4 py-3 text-sm">
<template x-if="p.primary_email">
<span class="text-xs" x-text="p.primary_email"></span>
</template>
<span x-show="!p.primary_email" class="text-gray-400"></span>
</td>
<td class="px-4 py-3 text-sm">
<a :href="'/admin/prospecting/prospects/' + p.id"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300">
View
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ table_empty('No prospects found') }}
</div>
{{ pagination_controls() }}
<!-- Create Prospect Modal -->
<div x-show="showCreateModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showCreateModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showCreateModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">New Prospect</h3>
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<!-- Channel Toggle -->
<div class="flex mb-4 space-x-2">
<button @click="newProspect.channel = 'digital'"
:class="newProspect.channel === 'digital' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg">
Digital (Domain)
</button>
<button @click="newProspect.channel = 'offline'"
:class="newProspect.channel === 'offline' ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg">
Offline (Manual)
</button>
</div>
<!-- Digital Fields -->
<div x-show="newProspect.channel === 'digital'" class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Domain Name</label>
<input type="text" x-model="newProspect.domain_name" placeholder="example.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Offline Fields -->
<div x-show="newProspect.channel === 'offline'" class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Business Name *</label>
<input type="text" x-model="newProspect.business_name" placeholder="Business name"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Phone</label>
<input type="tel" x-model="newProspect.phone" placeholder="+352..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Email</label>
<input type="email" x-model="newProspect.email" placeholder="contact@..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">City</label>
<input type="text" x-model="newProspect.city" placeholder="Luxembourg"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Source</label>
<select x-model="newProspect.source"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="street">Street</option>
<option value="networking_event">Networking Event</option>
<option value="referral">Referral</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label>
<textarea x-model="newProspect.notes" rows="3" placeholder="Any notes about this business..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showCreateModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<button @click="createProspect()"
:disabled="creating"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="!creating">Create</span>
<span x-show="creating">Creating...</span>
</button>
</footer>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('prospecting_static', path='admin/js/prospects.js') }}"></script>
{% endblock %}

View File

@@ -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') }}
<!-- Quick Actions -->
<div class="mb-6 flex flex-wrap gap-3">
<button @click="startBatchJob('http_check')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
HTTP Check
</button>
<button @click="startBatchJob('tech_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
<span x-html="$icon('code', 'w-4 h-4 mr-2')"></span>
Tech Scan
</button>
<button @click="startBatchJob('performance_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700">
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Performance Scan
</button>
<button @click="startBatchJob('contact_scrape')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
Contact Scrape
</button>
<button @click="startBatchJob('score_compute')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
<span x-html="$icon('target', 'w-4 h-4 mr-2')"></span>
Compute Scores
</button>
</div>
{{ loading_state('Loading scan jobs...') }}
{{ error_state('Error loading scan jobs') }}
<!-- Jobs Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Job Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Progress</th>
<th class="px-4 py-3">Started</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Result</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm font-semibold" x-text="job.job_type.replace('_', ' ')"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="jobStatusClass(job.status)"
x-text="job.status"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<div class="w-24 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
<div class="h-2 bg-blue-500 rounded-full" :style="'width: ' + (job.progress_percent || 0) + '%'"></div>
</div>
<span class="text-xs" x-text="job.processed_items + '/' + job.total_items"></span>
</div>
</td>
<td class="px-4 py-3 text-xs" x-text="job.started_at ? new Date(job.started_at).toLocaleString() : '—'"></td>
<td class="px-4 py-3 text-xs" x-text="formatDuration(job)"></td>
<td class="px-4 py-3 text-xs">
<span x-show="job.failed_items > 0" class="text-red-600" x-text="job.failed_items + ' failed'"></span>
<span x-show="job.skipped_items > 0" class="text-yellow-600 ml-1" x-text="job.skipped_items + ' skipped'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('prospecting_static', path='admin/js/scan-jobs.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1 @@
# app/modules/prospecting/tests/__init__.py

View File

@@ -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

View File

@@ -0,0 +1 @@
# app/modules/prospecting/tests/unit/__init__.py

View File

@@ -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

View File

@@ -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 = '<link rel="stylesheet" href="/wp-content/themes/test/style.css">'
assert self.service._detect_cms(html) == "wordpress"
def test_detect_cms_drupal(self):
"""Test CMS detection for Drupal."""
html = '<meta name="generator" content="drupal 7">'
assert self.service._detect_cms(html) == "drupal"
def test_detect_cms_none(self):
"""Test CMS detection when no CMS detected."""
html = "<html><body>Hello world</body></html>"
assert self.service._detect_cms(html) is None
def test_detect_js_framework_jquery(self):
"""Test JS framework detection for jQuery."""
html = '<script src="/js/jquery.min.js"></script>'
assert self.service._detect_js_framework(html) == "jquery"
def test_detect_js_framework_react(self):
"""Test JS framework detection for React."""
html = '<script id="__NEXT_DATA__" type="application/json">{}</script>'
assert self.service._detect_js_framework(html) == "react"
def test_detect_analytics_google(self):
"""Test analytics detection for Google Analytics."""
html = '<script async src="https://www.googletagmanager.com/gtag/js?id=G-123"></script>'
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 = "<html><body>No analytics here</body></html>"
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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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 == []

View File

@@ -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",

View File

@@ -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