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

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