Files
MarketingTool/apps/web/app.js
T

196 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ProspectOS frontend MVP. Configure before loading with window.API_BASE = 'http://127.0.0.1:8000'; */
(() => {
'use strict';
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
const endpoint = (path) => `${API_BASE}${path}`;
let prospects = [], selectedId = null, selectedDetail = null, currentUser = null;
let page = 1, pageSize = 10, hasNextPage = false;
const $ = (id) => document.getElementById(id);
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low';
const statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.verified || p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review');
const freshness = (p) => { const raw=p.updated_at||p.last_checked_at||p.created_at; if(!raw)return {label:'Unknown',cls:'stale'}; const days=Math.max(0,Math.floor((Date.now()-new Date(raw).getTime())/86400000)); return {label:days===0?'Today':`${days}d ago`,cls:days<=7?'good':'stale'}; };
const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors||p.factors||[]).reduce((n,f)=>n+({named_business:20,business_site:30,email:25,phone:15,description:10}[f]||0),0);
const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' '));
async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} if(response.status===403)throw new Error('Tenant/workspace access denied.'); return response; }
async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; }
function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;}
function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();}
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=prospects.filter(p=>scoreFor(p)>=80).length,review=prospects.filter(p=>statusOf(p)==='review').length,fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=summary?.needs_review??review;$('metricHigh').textContent=summary?.high_fit??high;$('metricFresh').textContent=`${summary?.freshness_under_7d??fresh}%`;}
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value};}
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new';return(!q||text.includes(q.toLowerCase()))&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps);});}
function renderRows(){const rows=filtered();$('resultCount').textContent=`Showing ${rows.length} prospect${rows.length===1?'':'s'} · page ${page}`;$('prospectRows').innerHTML=rows.length?rows.map(p=>{const s=scoreFor(p),f=freshness(p),st=statusOf(p),factors=p.score_factors||p.factors||[];return `<tr data-id="${esc(p.id)}" class="${Number(p.id)===Number(selectedId)?'selected':''}"><td>${esc(p.name)}<span class="company-sub">${esc(p.website_domain||'no detected website')}</span></td><td><span class="score ${scoreClass(s)}">${s} <small>/ 100</small></span></td><td class="evidence"><strong>${factors.length} signal${factors.length===1?'':'s'}</strong>${esc(factors.slice(0,2).map(labelFactor).join(' · ')||'Limited evidence')}</td><td><span class="fresh ${f.cls}">${f.label}</span></td><td><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></td><td class="row-arrow"></td></tr>`;}).join(''):'<tr><td colspan="6" class="muted">No prospects match these filters.</td></tr>';$('nextPageBtn').disabled=!hasNextPage;document.querySelectorAll('#prospectRows tr[data-id]').forEach(row=>row.addEventListener('click',()=>selectProspect(row.dataset.id)));}
function listQuery(){const f=filterValues(),params=new URLSearchParams({page:String(page),page_size:String(pageSize)});if(f.q)params.set('q',f.q);if(f.website_class!=='all')params.set('website_class',f.website_class);if(f.pipeline_stage!=='all')params.set('pipeline_stage',f.pipeline_stage);return `?${params}`;}
async function loadData(){ $('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');try{const [listRes,summaryRes]=await Promise.all([request(`/api/v1/businesses${listQuery()}`),request('/api/v1/dashboard/summary')]);if(!listRes.ok||!summaryRes.ok)throw new Error('API request failed');const list=await listRes.json(),summary=await summaryRes.json();prospects=Array.isArray(list)?list:(list.businesses||list.items||[]);hasNextPage=Boolean(list.has_next??list.next_page??list.next??list.next_cursor);renderMetrics(summary);$('apiStatus').textContent='● API connected';$('apiStatus').classList.add('live');}catch(error){if(error.message==='unauthorized')return;prospects=[];hasNextPage=false;renderMetrics(null);$('apiStatus').textContent='● API unavailable';}renderRows();if(selectedId)loadDetail(selectedId);}
async function selectProspect(id){selectedId=Number(id);selectedDetail=null;renderRows();$('detailPanel').innerHTML='<div class="detail-loading" aria-live="polite">Loading prospect detail…</div>';await loadDetail(selectedId);}
async function loadDetail(id){try{const detail=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}`);selectedDetail=detail;const index=prospects.findIndex(p=>Number(p.id)===Number(id));if(index>=0)prospects[index]={...prospects[index],...detail};renderDetail(detail);}catch(error){if(error.message!=='unauthorized')$('detailPanel').innerHTML=`<div class="detail-error" role="alert"><h3>Unable to load detail</h3><p>${esc(error.message)}</p><button class="button ghost" id="retryDetailBtn" type="button">Try again</button></div>`;}}
const listItems=(items,empty,label)=>Array.isArray(items)&&items.length?`<ul class="detail-list">${items.map(item=>`<li>${esc(typeof item==='string'?item:item[label]||item.value||item.name||JSON.stringify(item))}</li>`).join('')}</ul>`:`<p class="muted">${empty}</p>`;
const domainValue = item => typeof item === 'string' ? item : item?.domain || item?.hostname || item?.name || item?.value || '';
const domainForProspect = p => p?.website_domain || (Array.isArray(p?.domains) ? domainValue(p.domains[0]) : '') || '';
const domainState = value => { const state=String(value ?? '').toLowerCase().replaceAll('_','-'); return state==='nxdomain'?'NXDOMAIN':state==='error'?'Error':state==='not-configured'?'Not configured':state==='unknown'||!state?'Unknown':state.replaceAll('-',' '); };
const capabilityState = value => { if (value === true || ['present','found','yes','true','ok'].includes(String(value).toLowerCase())) return 'Present'; if (value === false || ['absent','not-found','no','false'].includes(String(value).toLowerCase())) return 'Not found'; return domainState(value); };
const checkedTime = item => item?.checked_at || item?.checkedAt || item?.last_checked_at || item?.checked_time || 'Time unavailable';
const cacheState = item => item?.cache_hit === true ? 'Hit' : item?.cache_state || item?.cacheStatus || item?.cache || (item?.cache_hit === false ? 'Miss' : 'Unknown');
const confidenceText = item => item?.confidence ?? item?.score ?? 'Unknown';
const reasonsText = item => { const reasons=item?.reasons || item?.reason || item?.explanation || []; return Array.isArray(reasons) ? reasons : [reasons]; };
function renderDomainPanel(p){
const panel=$('domainIntelligencePanel'); if(!panel)return;
panel.innerHTML=`<div class="domain-panel-heading"><div><p class="eyebrow">DOMAIN INTELLIGENCE</p><h4>DNS checks</h4></div><button class="button ghost compact" id="runDomainCheckBtn" type="button">↻ Check DNS</button></div><p class="domain-safety">DNS results are evidence only. Unknown, error, and NXDOMAIN states are never treated as ownership or availability.</p><div id="domainCheckState" class="domain-check-state"><div class="detail-loading" aria-live="polite">Loading DNS results…</div></div><div class="candidate-domain-panel" id="candidateDomainPanel"><div class="detail-loading" aria-live="polite">Loading candidate domains…</div></div>`;
loadDomainIntelligence(p.id);
}
function renderDomainCheck(result){
const el=$('domainCheckState'); if(!el)return;
if(!result){el.innerHTML='<p class="muted">No DNS check has been returned. Run a check to see evidence.</p>';return;}
const dns=result.dns || result.dns_state || result.state || result.status || 'unknown', records=result.records || result.capabilities || result;
const capability=(key, aliases=[])=>{for(const k of [key,...aliases])if(records?.[k]!==undefined)return records[k];return undefined;};
el.innerHTML=`<div class="domain-check-meta"><span class="domain-state ${String(dns).toLowerCase().replaceAll('_','-')}">${esc(domainState(dns))}</span><span>Checked ${esc(checkedTime(result))}</span><span>Cache: ${esc(domainState(cacheState(result)))}</span></div><dl class="dns-capabilities">${['A','AAAA','MX','NS','TXT'].map(type=>`<div><dt>${type}</dt><dd>${esc(capabilityState(capability(type,type.toLowerCase())))}</dd></div>`).join('')}</dl>${result.error||result.error_code||result.message?`<p class="domain-error" role="alert">${esc(result.error||result.error_code||result.message)}</p>`:''}`;
}
function renderCandidates(payload){
const panel=$('candidateDomainPanel'); if(!panel)return;
const candidates=payloadItems(payload,['candidates','candidate_domains','domains','items']);
panel.innerHTML=`<div class="domain-panel-heading"><div><p class="eyebrow">CANDIDATE DOMAINS</p><h4>Possible matches <span class="count">${candidates.length}</span></h4></div><span class="small-label">Human review required</span></div>${candidates.length?`<div class="candidate-list">${candidates.map((item,index)=>{const domain=domainValue(item);return `<article class="candidate-card" data-candidate-index="${index}"><div class="candidate-head"><strong>${esc(domain||'Unknown domain')}</strong><span class="match-confidence">${esc(confidenceText(item))} confidence</span></div><ul class="match-reasons">${reasonsText(item).map(reason=>`<li>${esc(typeof reason==='string'?reason:reason?.text||reason?.description||JSON.stringify(reason))}</li>`).join('')||'<li>Reason unavailable</li>'}</ul><button class="button ghost compact" type="button" data-domain-availability="${esc(domain)}">Check availability</button><p class="availability-result" aria-live="polite"></p></article>`;}).join('')}</div>`:'<p class="muted">No candidate domains returned.</p>'}<p class="domain-safety compact-safety">Availability is not configured here. This action will never claim a domain is available or owned.</p>`;
}
async function loadDomainIntelligence(id){
const check=$('domainCheckState'), candidates=$('candidateDomainPanel');
try{const [checkPayload,candidatePayload]=await Promise.all([jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/domains/check?domain=${encodeURIComponent(domainForProspect(selectedDetail || prospects.find(item=>Number(item.id)===Number(id)) || {}))}`),jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/domain-candidates`)]);renderDomainCheck(checkPayload);renderCandidates(candidatePayload);}catch(error){if(error.message!=='unauthorized'){if(check)check.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load DNS results</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryDomainBtn" type="button">Try again</button></div>`;if(candidates)candidates.innerHTML='<p class="muted">Candidate domains unavailable.</p>';}}
}
async function runDomainCheck(){const button=$('runDomainCheckBtn');if(!selectedId||!button)return;button.disabled=true;const state=$('domainCheckState');if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Checking DNS…</div>';try{const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/domains/check`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:domainForProspect(selectedDetail||{})})});renderDomainCheck(result);}catch(error){if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>DNS check failed</strong><p>${esc(error.message)}</p></div>`;}finally{button.disabled=false;}}
async function checkDomainAvailability(domain,button){const result=button.closest('.candidate-card')?.querySelector('.availability-result');if(!domain||!result)return;button.disabled=true;result.textContent='Checking availability…';try{const body=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/domain-candidates/check-availability`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain})});const state=body?.status||body?.state||body?.availability||'unknown';result.textContent=['not-configured','unknown','error'].includes(String(state).toLowerCase())?`Availability: ${domainState(state)}. No ownership conclusion.`:'Availability: Unknown. No ownership conclusion.';}catch(error){if(error.message!=='unauthorized')result.textContent=`Availability: Unknown. ${error.message}`;}finally{button.disabled=false;}}
const websiteScanValue = (scan, keys, fallback='Unknown') => { for (const key of keys) if (scan?.[key] !== undefined && scan[key] !== null && scan[key] !== '') return scan[key]; return fallback; };
const websiteStateLabel = value => { const state=String(value ?? 'unknown').toLowerCase().replaceAll('_','-'); return ({queued:'Queued',running:'Running',succeeded:'Complete',completed:'Complete',healthy:'Healthy',broken:'Broken','under-construction':'Under construction',parked:'Parked',placeholder:'Placeholder','redirect-only':'Redirect only',blocked:'Blocked',unknown:'Unknown',error:'Error'})[state] || state.replaceAll('-',' ').replace(/\b\w/g,c=>c.toUpperCase()); };
const signalLabel = (value, positive='Present') => value === true ? positive : value === false ? 'Not found' : websiteStateLabel(value);
const websiteScanFor = p => p?.website_scan || p?.websiteScan || p?.scan || null;
const websiteUrlFor = p => p?.website || p?.website_url || p?.url || (domainForProspect(p) ? `https://${domainForProspect(p)}` : '');
function renderWebsiteScanPanel(p){
const panel=$('websiteScanPanel'); if(!panel)return;
panel.innerHTML=`<div class="website-scan-heading"><div><p class="eyebrow">WEBSITE ANALYSIS</p><h4>Website scan</h4></div><div class="website-scan-actions"><button class="button ghost compact" id="refreshWebsiteScanBtn" type="button">↻ Refresh</button><button class="button primary compact" id="scanWebsiteBtn" type="button">Scan website</button></div></div><p class="website-scan-safety">Website analysis is authenticated workspace evidence from the HTTP scanner. It does not enable outreach.</p><div id="websiteScanState" aria-live="polite">${websiteScanFor(p)?renderWebsiteScan(websiteScanFor(p)):'<p class="muted">No website scan has been returned. Start a scan to see HTTP and contact signals.</p>'}</div>`;
loadWebsiteScan(p.id);
}
function renderWebsiteScan(scan){
const status=websiteStateLabel(websiteScanValue(scan,['scan_status','status','state'])), classification=websiteStateLabel(websiteScanValue(scan,['classification','class'],'unknown')), http=websiteScanValue(scan,['http_status','status_code','status'], 'Unknown'), finalUrl=websiteScanValue(scan,['final_url','url'],'Unknown'), chain=websiteScanValue(scan,['redirect_chain','redirects'],[]), tls=scan?.tls===true||scan?.https===true||scan?.certificate_status==='valid'?'Secure':scan?.tls===false||scan?.https===false?'Not secure':websiteStateLabel(scan?.certificate_status||'Unknown');
const contact=[['Contact page',scan?.contact_page_signal],['Email',scan?.mail_signal],['Phone',scan?.phone_signal],['WhatsApp',scan?.whatsapp_signal],['Social',scan?.social_signal],['Form',scan?.form_signal]];
const value=v=>Array.isArray(v)?(v.length?v.join(' → '):'None'):v;
const error=scan?.error||scan?.error_code||scan?.message;
return `<div class="website-scan-meta"><span><b>Scan status</b><span class="website-status ${String(status).toLowerCase().replaceAll(' ','-')}">${esc(status)}</span></span><span>Checked ${esc(websiteScanValue(scan,['checked_at','last_checked_at','checked_time']))}</span><span>Cache: ${esc(websiteStateLabel(websiteScanValue(scan,['cache_state','cache_status','cache'], 'Unknown')))}</span></div>${error?`<div class="website-scan-error" role="alert"><strong>${esc(websiteStateLabel(scan.error_code||'Error'))}</strong><p>${esc(error)}</p></div>`:''}<div class="website-scan-grid"><div><dt>HTTP status</dt><dd>${esc(http)}</dd></div><div><dt>Final URL</dt><dd class="breakable">${esc(finalUrl)}</dd></div><div><dt>Redirect chain</dt><dd class="breakable">${esc(value(chain)||'None')}</dd></div><div><dt>Classification</dt><dd>${esc(classification)}</dd></div><div><dt>Title</dt><dd>${esc(websiteScanValue(scan,['title']))}</dd></div><div><dt>Meta description</dt><dd>${esc(websiteScanValue(scan,['meta_description','description']))}</dd></div><div><dt>Viewport</dt><dd>${esc(scan?.responsive_signal===true?'Responsive signal present':scan?.viewport||'Unknown')}</dd></div><div><dt>HTTPS / TLS</dt><dd>${esc(tls)}${scan?.certificate_status?` · ${esc(websiteStateLabel(scan.certificate_status))}`:''}</dd></div><div><dt>Load time</dt><dd>${esc(websiteScanValue(scan,['elapsed_ms','load_time_ms','duration_ms'],'Unknown'))}${scan?.elapsed_ms!=null?' ms':''}</dd></div><div><dt>Response size</dt><dd>${esc(websiteScanValue(scan,['response_size','size_bytes','body_size'],'Unknown'))}${scan?.size_bytes!=null?' bytes':''}</dd></div></div><div class="website-contact-signals"><h5>Contact signals</h5><div class="signal-list">${contact.map(([label,val])=>`<span class="signal ${val===true?'present':val===false?'absent':'unknown'}"><b>${esc(label)}</b>${esc(signalLabel(val))}</span>`).join('')}</div></div>`;
}
async function loadWebsiteScan(id, {scan=false}={}){const state=$('websiteScanState'), scanButton=$('scanWebsiteBtn'), refreshButton=$('refreshWebsiteScanBtn');if(!state||!id)return;[scanButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='<div class="detail-loading" aria-live="polite">Loading website scan…</div>';try{const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/websites/scan`,scan?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:websiteUrlFor(selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{})})}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,website_scan:result};state.innerHTML=renderWebsiteScan(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>${scan?'Website scan failed':'Unable to load website scan'}</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryWebsiteScanBtn" type="button">Try again</button></div>`;}finally{[scanButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}}
const extractedContactItems = payload => { for (const key of ['contacts','extracted_contacts','items','results']) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; };
const extractedContactValue = (item, keys, fallback='Unknown') => { for (const key of keys) if (item?.[key] !== undefined && item[key] !== null && item[key] !== '') return item[key]; return fallback; };
const extractedContactStatus = item => { const status=String(extractedContactValue(item,['suppression_status','suppression','status'],'Unknown')).toLowerCase().replaceAll('_','-'); return status==='suppressed'||item?.suppressed===true ? 'Suppressed' : status==='not-suppressed'||status==='allowed'||item?.suppressed===false ? 'Not suppressed' : status.replaceAll('-',' ').replace(/\\b\\w/g,c=>c.toUpperCase()); };
function renderExtractedContacts(payload){
const state=$('contactExtractionState'); if(!state)return;
if(!payload){state.innerHTML='<p class="muted">No extracted contacts returned. Extract from approved public business pages to review contact evidence.</p>';return;}
const contacts=extractedContactItems(payload);
if(!contacts.length){state.innerHTML='<div class="contact-extraction-empty"><strong>No public contacts found</strong><p class="muted">No contact details were found on the approved public business pages checked.</p></div>';return;}
state.innerHTML=`<div class="extracted-contact-list">${contacts.map(item=>{const type=extractedContactValue(item,['type','contact_type','kind'],'Other'),email=extractedContactValue(item,['email','value','address']),emailClass=extractedContactValue(item,['email_class','classification','class'],'Unknown'),validation=extractedContactValue(item,['validation','validation_status','verified_status'],'Unknown'),confidence=extractedContactValue(item,['confidence','confidence_score','score']),source=extractedContactValue(item,['source_url','url','source'],'Unknown source URL'),suppression=extractedContactStatus(item);return `<article class="extracted-contact" data-contact-type="${esc(type)}"><div class="extracted-contact-head"><strong>${esc(email)}</strong><span class="contact-confidence">${esc(confidence)} confidence</span></div><dl class="extracted-contact-facts"><div><dt>Type</dt><dd>${esc(type)}</dd></div><div><dt>Email class</dt><dd>${esc(emailClass)}</dd></div><div><dt>Validation</dt><dd>${esc(validation)}</dd></div><div><dt>Suppression</dt><dd class="suppression-status">${esc(suppression)}</dd></div><div class="contact-source"><dt>Source URL</dt><dd class="breakable"><a href="${esc(source)}" target="_blank" rel="noreferrer">${esc(source)}</a></dd></div></dl></article>`;}).join('')}</div>`;
}
function renderContactExtractionPanel(p){const panel=$('contactExtractionPanel');if(!panel)return;panel.innerHTML=`<div class="contact-extraction-heading"><div><p class="eyebrow">PUBLIC CONTACT EXTRACTION</p><h4>Extracted contacts <span class="count">${extractedContactItems(p.extracted_contacts||p.contact_extraction).length}</span></h4></div><div class="contact-extraction-actions"><button class="button ghost compact" id="refreshContactExtractionBtn" type="button">↻ Refresh</button><button class="button primary compact" id="extractContactsBtn" type="button">Extract public contacts</button></div></div><p class="contact-extraction-safety">Extraction uses approved public business pages only. It does not probe SMTP, verify mailbox access, or send outreach.</p><div id="contactExtractionState" aria-live="polite">${p.extracted_contacts||p.contact_extraction?renderExtractedContactsMarkup(p.extracted_contacts||p.contact_extraction):'<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>'}</div>`;}
function renderExtractedContactsMarkup(payload){const contacts=extractedContactItems(payload);if(!contacts.length)return '<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>';return '<p class="muted">Previously extracted contacts are available. Refresh to check approved public business pages again.</p>';}
async function loadContactExtraction(id,{extract=false}={}){const state=$('contactExtractionState'),extractButton=$('extractContactsBtn'),refreshButton=$('refreshContactExtractionBtn');if(!state||!id)return;[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='<div class="detail-loading" aria-live="polite">Loading public contacts…</div>';try{const scan=selectedDetail?.website_scan||selectedDetail?.websiteScan||{};const payload={approved_public_pages_only:true,smtp_probing:false,outreach:false,website_scan_id:scan.id,source_url:scan.final_url||scan.input_url,html:scan.html};const result=await jsonRequest(extract?`/api/v1/businesses/${encodeURIComponent(id)}/contacts/extract`:`/api/v1/contact-extractions?business_id=${encodeURIComponent(id)}`,extract?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,extracted_contacts:result.contacts||result.extracted_contacts||result.items||result};renderExtractedContacts(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>${extract?'Contact extraction failed':'Unable to load extracted contacts'}</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryContactExtractionBtn" type="button">Try again</button></div>`;}finally{[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}}
const scoreItems = payload => payloadItems(payload, ['rules','score_rules','items','factors']);
const scoreValue = (item, keys, fallback='Unknown') => { for (const key of keys) if (item?.[key] !== undefined && item[key] !== null && item[key] !== '') return item[key]; return fallback; };
const scoreRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase();
const canEditScores = () => ['owner','admin'].includes(scoreRole()) || Boolean(currentUser?.permissions?.includes?.('score_rules:write'));
const scoreExplanation = factor => typeof factor === 'string' ? {name:labelFactor(factor), points:scoreValue(({named_business:20,business_site:30,email:25,phone:15,description:10}),[factor],0)} : factor || {};
function renderScoreConfig(payload) { const panel=$('scoreRulesPanel'); if(!panel)return; const rules=scoreItems(payload), version=scoreValue(payload,['version','score_version','scoring_version'],'Unknown'), editable=canEditScores(); panel.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">CONFIGURATION</p><h3>Score rules</h3></div><span class="small-label">${editable?'Admin controls available':'Read-only for this role'}</span></div><p class="score-config-note">Rules are workspace configuration. ${editable?'Changes require an authorized admin action.':'Your role can view rules but cannot edit them.'}</p><div class="score-rule-table">${rules.length?rules.map(rule=>`<div class="score-config-row"><strong>${esc(scoreValue(rule,['label','name','rule','key'],'Scoring rule'))}</strong><span>${scoreValue(rule,['enabled','active'],false)?'Enabled':'Disabled'}</span><span>${esc(scoreValue(rule,['points','weight','value'],0))} pts</span><small>v${esc(scoreValue(rule,['version','rule_version'],version))}</small>${editable?`<button class="button ghost compact" type="button" data-score-edit="${esc(rule.id)}" data-score-points="${esc(scoreValue(rule,['points','weight','value'],0))}">Edit points</button>`:''}</div>`).join(''):'<p class="muted">No score rules returned by the workspace.</p>'}</div><p class="small-label">Configuration version: ${esc(version)}</p>`; }
function renderScoreDistribution(payload) { const panel=$('scoreDistributionPanel'); if(!panel)return; const buckets=payloadItems(payload,['distribution','buckets','items']); const bandMap=payload?.bands&&typeof payload.bands==='object'&&!Array.isArray(payload.bands)?payload.bands:{}; const summary=payload?.summary||payload||{}; const fallback=[['High priority',summary.high_priority??bandMap.high??0],['Medium priority',bandMap.medium??0],['Low priority',bandMap.low??0],['Ineligible',bandMap.ineligible??0]]; const rows=buckets.length?buckets.map(item=>[scoreValue(item,['label','band','name'],'Score band'),scoreValue(item,['count','total','value'],0)]):fallback; const total=rows.reduce((n,row)=>n+Number(row[1]||0),0); panel.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">SCORING SUMMARY</p><h2>Score distribution</h2></div><span class="small-label">${total} prospects</span></div><div class="score-distribution-list">${rows.map(([label,count])=>`<div class="distribution-row"><span>${esc(label)}</span><strong>${esc(count)}</strong><span class="distribution-track"><i style="width:${total?Math.min(100,Number(count)/total*100):0}%"></i></span></div>`).join('')}</div><p class="score-safety">Distribution is tenant-scoped and reflects API results only.</p>`; }
async function loadScorePanels() { const distribution=$('scoreDistributionPanel'), rules=$('scoreRulesPanel'); if(distribution)distribution.innerHTML='<div class="detail-loading" aria-live="polite">Loading score distribution…</div>'; if(rules)rules.innerHTML='<div class="detail-loading" aria-live="polite">Loading score rules…</div>'; try { const [distributionPayload,rulesPayload]=await Promise.all([jsonRequest('/api/v1/scoring/summary'),jsonRequest('/api/v1/score-rules')]); renderScoreDistribution(distributionPayload); renderScoreConfig(rulesPayload); } catch(error) { if(error.message!=='unauthorized'){ if(distribution)distribution.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load score distribution</strong><p>${esc(error.message)}</p></div>`; if(rules)rules.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load score rules</strong><p>${esc(error.message)}</p></div>`; } } }
async function updateScoreRule(id, currentPoints) { if(!canEditScores()||!id)return; const value=window.prompt('Points for this rule (0100):',currentPoints); if(value===null)return; const points=Number(value); if(!Number.isInteger(points)||points<0||points>100){window.alert('Enter a whole number from 0 to 100.');return;} try { await jsonRequest(`/api/v1/score-rules/${encodeURIComponent(id)}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({points})}); await loadScorePanels(); } catch(error) { if(error.message!=='unauthorized')window.alert(error.message||'Unable to update score rule.'); } }
function renderScoreBreakdown(p) {
const panel=$('scoreBreakdownPanel'); if(!panel)return;
const score=scoreFor(p), factors=p.score_factors||p.factors||[], eligible=scoreValue(p,['eligible','score_eligible','eligibility'],'Unknown'), priority=scoreValue(p,['priority','priority_band','score_priority'],score>=80?'High':score>=60?'Medium':'Low'), version=scoreValue(p,['score_version','scoring_version'],'Unknown');
panel.innerHTML=`<div class="score-panel-heading"><div><p class="eyebrow">SCORING</p><h4>Score breakdown</h4></div><button class="button ghost compact" id="recalculateScoreBtn" type="button">↻ Recalculate</button></div><div class="score-breakdown-summary"><div><small>Total score</small><strong class="score-total ${scoreClass(score)}">${esc(score)}<small>/ 100</small></strong></div><div><small>Priority</small><b>${esc(priority)}</b></div><div><small>Eligibility</small><b>${esc(eligible)}</b></div></div><dl class="score-meta"><div><dt>Score version</dt><dd>${esc(version)}</dd></div><div><dt>Signals</dt><dd>${factors.length}</dd></div></dl><div class="score-rules-explanation"><h5>Rule explanations</h5>${factors.length?factors.map(raw=>{const item=scoreExplanation(raw);return `<div class="score-rule-row"><span>${esc(scoreValue(item,['label','name','rule','factor'],'Scoring signal'))}</span><strong>${esc(scoreValue(item,['points','value','weight'],0))} pts</strong><small>${esc(scoreValue(item,['explanation','reason','description'],'Observed evidence contributes to this score.'))}</small></div>`}).join(''):'<p class="muted">No scoring signals returned.</p>'}</div><p class="score-safety">Scores are review signals only. They do not override suppression or authorize outreach.</p><p id="scoreMessage" class="form-message" role="status" aria-live="polite"></p>`;
}
async function recalculateScore() { const button=$('recalculateScoreBtn'), messageEl=$('scoreMessage'); if(!button||!selectedId)return; button.disabled=true; if(messageEl)messageEl.textContent='Recalculation requested…'; try { const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/score/recalculate`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}); if(messageEl)messageEl.textContent=result?.status==='queued'?'Recalculation queued. Refresh when the job completes.':'Score recalculation accepted. Refresh to see the API result.'; } catch(error) { if(error.message!=='unauthorized'&&messageEl){messageEl.textContent=error.message||'Unable to recalculate score.';messageEl.className='form-message error';} } finally {button.disabled=false;} }
function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain||'no detected website')}</p></div><span class="status ${st}">${esc(review)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><section class="detail-block score-breakdown-panel" id="scoreBreakdownPanel" data-smoke="score-breakdown"><div class="detail-loading">Loading score breakdown…</div></section><div class="detail-block"><h4>Pipeline stage</h4><form id="pipelineForm" class="inline-form"><select name="stage" aria-label="Pipeline stage"><option value="new" ${stage==='new'?'selected':''}>New</option><option value="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="review" ${stage==='review'?'selected':''}>Review</option><option value="suppressed" ${stage==='suppressed'?'selected':''}>Suppressed</option></select><button class="button ghost compact" type="submit">Save stage</button></form><p id="pipelineMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Contacts <span class="count">${contacts.length}</span></h4>${listItems(contacts,'No contacts added.','email')}<form id="contactForm" class="compact-form"><input name="name" placeholder="Contact name" aria-label="Contact name"><input name="email" type="email" placeholder="Email" aria-label="Contact email" required><button class="button ghost compact" type="submit">Add contact</button></form><p id="contactMessage" class="form-message" role="status"></p></div><section class="detail-block contact-extraction-panel" id="contactExtractionPanel" data-smoke="contact-extraction"><div class="detail-loading">Loading public contacts…</div></section><section class="detail-block website-scan-panel" id="websiteScanPanel" data-smoke="website-scan"><div class="detail-loading">Loading website scan…</div></section><section class="detail-block domain-intelligence" id="domainIntelligencePanel" data-smoke="domain-intelligence"><div class="detail-loading">Loading domain intelligence…</div></section><div class="detail-block"><h4>Domains & websites</h4>${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}</div><div class="detail-block"><h4>Evidence timeline</h4>${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence||'Medium')}</span></p>`).join(''):''}</div><div class="detail-block"><h4>Notes <span class="count">${notes.length}</span></h4>${listItems(notes,'No notes added.','body')}<form id="noteForm" class="compact-form"><textarea name="body" rows="2" placeholder="Add a review note…" required></textarea><button class="button ghost compact" type="submit">Add note</button></form><p id="noteMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Review status</h4><p class="review-status">${esc(review)}</p>${st!=='suppressed'?'<button class="button primary compact" id="verifyBtn" type="button">Mark verified</button>':''}<p id="verifyMessage" class="form-message" role="status"></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();}
let mergeSource = null, mergeTarget = null, mergeBusy = false;
const payloadItems = (payload, keys) => { for (const key of keys) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; };
const suggestionId = item => item.target_id ?? item.business_id ?? item.prospect_id ?? item.id;
const suggestionName = item => item.target_name || item.business_name || item.prospect_name || item.name || `Prospect ${suggestionId(item)}`;
const suggestionConfidence = item => item.confidence ?? item.score ?? item.match_confidence ?? 'Unknown';
const suggestionReasons = item => item.reasons || item.reason || item.match_reasons || item.explanation || [];
const reasonItems = reasons => Array.isArray(reasons) ? reasons : [reasons];
function renderDedupPanel() {
const detail = $('detailPanel'); if (!detail || !selectedId) return;
let panel = $('dedupPanel');
if (!panel) { panel = document.createElement('section'); panel.id = 'dedupPanel'; panel.className = 'dedup-panel'; panel.dataset.smoke = 'deduplication'; detail.appendChild(panel); }
panel.innerHTML = '<div class="detail-loading" aria-live="polite">Loading match suggestions…</div>';
loadMatchSuggestions(selectedId);
}
async function loadMatchSuggestions(id) {
const panel = $('dedupPanel'); if (!panel) return;
try {
const payload = await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/matches`);
const suggestions = payloadItems(payload, ['suggestions','matches','items']);
panel.innerHTML = `<div class="dedup-heading"><div><p class="eyebrow">DEDUPLICATION</p><h4>Possible matches <span class="count">${suggestions.length}</span></h4></div><span class="small-label">Human review required</span></div>${suggestions.length ? `<div class="match-list">${suggestions.map(item => { const confidence=String(suggestionConfidence(item)); const reasons=reasonItems(suggestionReasons(item)); return `<article class="match-card" data-match-id="${esc(suggestionId(item))}"><div class="match-card-head"><strong>${esc(suggestionName(item))}</strong><span class="match-confidence">${esc(confidence)} confidence</span></div><ul class="match-reasons">${reasons.map(reason => `<li>${esc(typeof reason === 'string' ? reason : reason.text || reason.description || JSON.stringify(reason))}</li>`).join('')}</ul><button class="button primary compact review-required" type="button" data-merge-target="${esc(suggestionId(item))}" data-merge-target-name="${esc(suggestionName(item))}">Review &amp; merge</button></article>`; }).join('')}</div>` : '<p class="muted">No possible matches returned. Nothing was merged automatically.</p>'}<div class="merge-history" id="mergeHistory" data-api-marker="merge-history"><div class="detail-loading" aria-live="polite">Loading merge history…</div></div>`;
loadMergeHistory(id);
} catch (error) { if (error.message !== 'unauthorized') panel.innerHTML = `<div class="dedup-error" role="alert"><h4>Unable to load match suggestions</h4><p>${esc(error.message)}</p><button class="button ghost compact" id="retryDedupBtn" type="button">Try again</button></div><div class="merge-history" id="mergeHistory"></div>`; }
}
async function loadMergeHistory(id) {
const history = $('mergeHistory'); if (!history) return;
try { const payload = await jsonRequest('/api/v1/merge-history'); const items = payloadItems(payload, ['history','merges','items']).filter(item => Number(item.source_business_id) === Number(id) || Number(item.target_business_id) === Number(id)); history.innerHTML = `<h4>Merge history <span class="count">${items.length}</span></h4>${items.length ? `<div class="history-list">${items.map(item => { const mergeId=item.merge_id||item.id, source=item.source_name||item.source_business_name||('Prospect '+(item.source_business_id||'source')), target=item.target_name||item.target_business_name||('Prospect '+(item.target_business_id||'target')); return `<article class="history-row"><div><strong>${esc(source)}${esc(target)}</strong><small>${esc(item.created_at||item.merged_at||'Time unavailable')} · ${esc(item.status||'Merged')}</small></div>${item.reversible !== false && item.reversed_at == null ? `<button class="button ghost compact" type="button" data-reverse-merge="${esc(mergeId)}">Reverse merge</button>` : `<span class="small-label">${item.reversed_at ? 'Reversed' : 'Not reversible'}</span>`}</article>`; }).join('')}</div>` : '<p class="muted">No merges recorded for this prospect.</p>'}`; } catch (error) { if (error.message !== 'unauthorized') history.innerHTML = `<div class="dedup-error" role="alert"><h4>Unable to load merge history</h4><p>${esc(error.message)}</p><button class="button ghost compact" id="retryHistoryBtn" type="button">Try again</button></div>`; }
}
function openMergeDialog(targetId, targetName) { mergeSource={id:selectedId,name:selectedDetail?.name||prospects.find(p=>Number(p.id)===Number(selectedId))?.name||`Prospect ${selectedId}`}; mergeTarget={id:targetId,name:targetName}; $('mergeDialogCopy').innerHTML=`You are about to merge <strong>${esc(mergeSource.name)}</strong> (source) into <strong>${esc(mergeTarget.name)}</strong> (target). Review both records before confirming.`; $('mergeDialogMessage').textContent=''; $('mergeDialogMessage').className='form-message'; $('mergeDialog').hidden=false; $('confirmMergeBtn').disabled=false; }
function closeMergeDialog() { if (mergeBusy) return; $('mergeDialog').hidden=true; mergeSource=null; mergeTarget=null; }
async function confirmMerge() { if (!mergeSource || !mergeTarget || mergeBusy) return; mergeBusy=true; const button=$('confirmMergeBtn'); button.disabled=true; $('mergeDialogMessage').textContent='Merging records…'; $('mergeDialogMessage').className='form-message'; try { await jsonRequest(`/api/v1/businesses/${encodeURIComponent(mergeSource.id)}/merge`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target_id:Number(mergeTarget.id),review_required:true})}); $('mergeDialogMessage').textContent='Merge completed and recorded in history.'; await loadData(); closeMergeDialog(); if (selectedId) await loadDetail(selectedId); } catch (error) { if (error.message !== 'unauthorized') { $('mergeDialogMessage').textContent=error.message||'Unable to merge records.'; $('mergeDialogMessage').className='form-message error'; button.disabled=false; } } finally { mergeBusy=false; } }
async function reverseMerge(id) { if (!window.confirm('Reverse this merge? The original records will be restored.')) return; try { await jsonRequest(`/api/v1/merge-history/${encodeURIComponent(id)}/reverse`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_required:true})}); if(selectedId) { await loadDetail(selectedId); await loadData(); } } catch(error) { const history=$('mergeHistory'); if(error.message!=='unauthorized'&&history) history.insertAdjacentHTML('afterbegin',`<p class="form-message error" role="alert">${esc(error.message||'Unable to reverse merge.')}</p>`); } }
function message(id,text,error=false){const el=$(id);if(el){el.textContent=text;el.className=`form-message${error?' error':''}`;}}
async function saveContact(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.email.trim()){message('contactMessage','Email is required.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/contacts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('contactMessage','Contact added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('contactMessage',e.message,true);}}
async function saveNote(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.body.trim()){message('noteMessage','Note cannot be empty.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/notes`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('noteMessage','Note added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('noteMessage',e.message,true);}}
async function saveStage(form){const stage=new FormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}}
async function verify(){try{await jsonRequest(`/api/v1/businesses/${selectedId}/verify`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verified:true})});message('verifyMessage','Prospect marked verified.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('verifyMessage',e.message,true);}}
async function addProspect(event){event.preventDefault();const data=Object.fromEntries(new FormData(event.currentTarget).entries());const msg=$('formMessage');try{const body=await jsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}}
let jobs = [], selectedJobId = null, jobPollTimer = null;
const jobStatuses = ['queued','running','succeeded','failed','cancelled'];
const jobRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase();
const canManageJobs = () => ['admin','owner','operator','manager'].includes(jobRole()) || Boolean(currentUser?.permissions?.includes?.('jobs:manage'));
const jobStatus = job => String(job?.status || job?.state || 'queued').toLowerCase();
const jobLabel = status => status.charAt(0).toUpperCase() + status.slice(1);
async function jobsRequest(path, options = {}) { return jsonRequest(path, options); }
function jobMessage(text, error = false) { const el = $('jobsMessage'); el.textContent = text || ''; el.className = `jobs-message${error ? ' error' : ''}`; }
function resetJobCounts() { jobStatuses.forEach(status => { const el = $(`jobCount${jobLabel(status)}`); if (el) el.textContent = '—'; }); }
function renderJobCounts(payload) {
const counts = payload?.counts || payload?.status_counts || payload?.summary || {};
const derived = jobs.reduce((out, job) => { const status = jobStatus(job); if (jobStatuses.includes(status)) out[status] += 1; return out; }, {queued:0,running:0,succeeded:0,failed:0,cancelled:0});
jobStatuses.forEach(status => { const value = counts[status] ?? counts[`${status}_count`] ?? derived[status]; $(`jobCount${jobLabel(status)}`).textContent = Number.isFinite(Number(value)) ? Number(value) : 0; });
}
function renderJobsList() {
const list = $('jobsList');
if (!jobs.length) { list.innerHTML = '<div class="job-empty">No jobs returned by the workspace.</div>'; return; }
list.innerHTML = jobs.map(job => { const status = jobStatus(job), progress = Number(job.progress ?? job.percent ?? 0); return `<button class="job-row ${Number(job.id) === Number(selectedJobId) ? 'selected' : ''}" type="button" data-job-id="${esc(job.id)}"><span class="job-row-main"><strong>${esc(job.name || job.type || `Job ${job.id}`)}</strong><small>${esc(job.created_at || job.submitted_at || 'Time unavailable')}</small></span><span class="job-row-state ${esc(status)}">${esc(jobLabel(status))}</span><span class="job-row-progress">${status === 'running' ? `${Math.max(0, Math.min(100, progress))}%` : ''}</span></button>`; }).join('');
}
const structuredError = job => job.error || job.structured_error || job.failure || (job.error_code ? {code:job.error_code,message:job.error_message || 'The job reported a structured error.'} : null);
const eventText = event => event.message || event.description || event.name || event.type || 'Job event';
function renderJobDetail(job, events = job.events || job.event_timeline || []) {
const status = jobStatus(job), error = structuredError(job), progress = Math.max(0, Math.min(100, Number(job.progress ?? job.percent ?? 0))), canCancel = canManageJobs() && ['queued','running'].includes(status), canRetry = canManageJobs() && status === 'failed';
$('jobDetailPanel').innerHTML = `<div class="job-detail-head"><div><p class="eyebrow">JOB DETAIL</p><h3>${esc(job.name || job.type || `Job ${job.id}`)}</h3><p class="muted">ID ${esc(job.id)}</p></div><span class="job-row-state ${esc(status)}">${esc(jobLabel(status))}</span></div><div class="job-progress" aria-label="Job progress"><div class="job-progress-meta"><strong>${status === 'running' ? `${progress}% complete` : jobLabel(status)}</strong><span>${esc(job.progress_message || job.message || '')}</span></div><div class="progress-track"><span style="width:${progress}%"></span></div></div>${error ? `<div class="structured-error" role="alert"><strong>${esc(error.code || error.type || 'Job error')}</strong><p>${esc(error.message || error.detail || error.description || JSON.stringify(error))}</p>${error.details ? `<pre>${esc(JSON.stringify(error.details, null, 2))}</pre>` : ''}</div>` : ''}<div class="job-detail-actions">${canCancel ? '<button class="button ghost compact" id="cancelJobBtn" type="button">Cancel job</button>' : ''}${canRetry ? '<button class="button primary compact" id="retryJobBtn" type="button">Retry job</button>' : ''}${!canCancel && !canRetry && !canManageJobs() ? '<span class="muted">Your role cannot change jobs.</span>' : ''}</div><div class="event-timeline"><h4>Event timeline</h4>${Array.isArray(events) && events.length ? `<ol>${events.map(event => `<li><span class="timeline-dot"></span><div><strong>${esc(eventText(event))}</strong><small>${esc(event.created_at || event.timestamp || event.at || '')}</small>${event.progress != null ? `<span class="timeline-progress">${esc(event.progress)}%</span>` : ''}</div></li>`).join('')}</ol>` : '<p class="muted">No events returned yet.</p>'}</div>`;
}
async function loadJobDetail(id) { selectedJobId = id; renderJobsList(); $('jobDetailPanel').innerHTML = '<div class="detail-loading" aria-live="polite">Loading job detail…</div>'; try { const job = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}`); let events = job.events || job.event_timeline; if (!events) { const eventPayload = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}/events`); events = eventPayload.events || eventPayload.items || eventPayload; } renderJobDetail(job, events); } catch (error) { if (error.message !== 'unauthorized') $('jobDetailPanel').innerHTML = `<div class="detail-error" role="alert"><h3>Unable to load job</h3><p>${esc(error.message)}</p><button class="button ghost" id="retryJobDetailBtn" type="button">Try again</button></div>`; } }
async function loadJobs({silent = false} = {}) { if (!silent) { jobMessage('Loading jobs…'); resetJobCounts(); } try { const payload = await jobsRequest('/api/v1/jobs'); jobs = Array.isArray(payload) ? payload : (payload.jobs || payload.items || []); renderJobCounts(payload); renderJobsList(); $('jobsUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; jobMessage(''); const active = jobs.some(job => ['queued','running'].includes(jobStatus(job))); if (active && !jobPollTimer) jobPollTimer = setInterval(() => loadJobs({silent:true}), 5000); if (!active && jobPollTimer) { clearInterval(jobPollTimer); jobPollTimer = null; } if (selectedJobId) { const selected = jobs.find(job => String(job.id) === String(selectedJobId)); if (selected) await loadJobDetail(selectedJobId); } } catch (error) { jobs = []; resetJobCounts(); renderJobsList(); if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to load jobs.', true); } }
async function startDemoJob() { if (!canManageJobs()) { jobMessage('Your role is not permitted to start jobs.', true); return; } const button = $('startDemoJobBtn'); button.disabled = true; jobMessage('Starting demo job…'); try { const job = await jobsRequest('/api/v1/jobs', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({type:'noop', payload:{}, idempotency_key:`demo-${Date.now()}-${Math.random().toString(36).slice(2)}`})}); await loadJobs({silent:true}); if (job?.id) await loadJobDetail(job.id); jobMessage('Demo job started.'); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to start demo job.', true); } finally { button.disabled = !canManageJobs(); } }
async function jobAction(action) { const job = jobs.find(item => String(item.id) === String(selectedJobId)); if (!job || !canManageJobs()) return; const endpointPath = action === 'cancel' ? `/api/v1/jobs/${encodeURIComponent(job.id)}/cancel` : `/api/v1/jobs/${encodeURIComponent(job.id)}/retry`; const label = action === 'cancel' ? 'cancel' : 'retry'; try { await jobsRequest(endpointPath, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); jobMessage(`Job ${label} requested.`); await loadJobs({silent:true}); await loadJobDetail(job.id); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || `Unable to ${label} job.`, true); } }
function updateJobPermissions() { if ($('startDemoJobBtn')) $('startDemoJobBtn').disabled = !canManageJobs(); }
let sources = [], selectedSourceId = null;
const sourceState = source => Boolean(source?.enabled ?? source?.active);
const sourceStatus = source => sourceState(source) ? 'enabled' : 'disabled';
const sourceItems = payload => Array.isArray(payload) ? payload : (payload?.sources || payload?.items || payload?.records || []);
function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } }
function renderSourceSelect() { const select = $('discoverySource'); if (!select) return; select.innerHTML = `<option value="">Select a source</option>${sources.map(s => `<option value="${esc(s.id)}">${esc(s.name || s.label || `Source ${s.id}`)} · ${sourceStatus(s)}</option>`).join('')}`; }
function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const status = sourceStatus(source), health = source.health || source.health_status || 'Not tested', terms = source.terms_reviewed ?? source.terms_status ?? 'Not reviewed', config = source.config || {}; return `<article class="source-row" data-source-id="${esc(source.id)}"><div class="source-row-main"><strong>${esc(source.name || source.label || `Source ${source.id}`)}</strong><small>${esc(source.kind || source.source_type || source.type || 'manual')}${source.url || config.url ? ` · ${esc(source.url || config.url)}` : ''}</small></div><span class="source-status ${status}">${status}</span><dl class="source-facts"><div><dt>Owner</dt><dd>${esc(source.owner || source.owner_name || config.owner || 'Not assigned')}</dd></div><div><dt>Terms</dt><dd>${esc(String(source.terms_reviewed ?? source.terms_status ?? (config.terms_url ? 'Provided' : 'Not reviewed')))}</dd></div><div><dt>Rate limit</dt><dd>${esc(source.rate_limit || source.rate_limit_label || config.rate_limit || 'Not set')}</dd></div><div><dt>Health</dt><dd>${esc(String(health))}</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id)}">Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id)}">${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); }
function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; }
async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/source-records?page_size=25')]); sources = sourceItems(sourcePayload); renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source) return; try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } }
async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } }
function parseCsv(text){const lines=text.trim().split(/\r?\n/).filter(Boolean),cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[];if(!lines.length)return[];const headers=cells(lines[0]);return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v])));}
function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`;}
async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();await loadScorePanels();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);});
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
bootstrap();
})();