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