@@ -74,6 +74,16 @@ Extraction is not a browser crawler. The browser must not fetch target pages dir
The API remains authoritative for official-site scope, tenant isolation, suppression enforcement, limits, retention, provenance, and permissions. A cached extraction must show its observed time and freshness, never “live.” Candidate confidence, role/free-mail labels, syntax, and MX/DNS uncertainty are review metadata only and cannot enable a contact action.
## Phase 10 scoring UI contract
The UI may display server-provided `score`, `priority_band`, and `eligibility` as separate fields. It should show the active rule-set ID/version, calculated time/freshness, explanation factors with their points/weights, exclusions, and stale/uncertain reasons. A band is a triage label, not permission to contact; never derive or override these values solely in browser code.
Render eligibility independently and prominently: **Eligible**, **Ineligible**, or **Unknown/review required** must not be collapsed into a score band. Show suppression/do-not-contact as a hard, persistent state that wins over score, verification, pipeline, cached data, or refresh. Keep stale, expired, blocked, partial, and uncertain evidence visibly distinct from missing evidence and never present them as a positive or negative fact. There is no outreach control in this phase.
If the API exposes recalculation, the UI must show the requested rule-set/version, job/progress/partial state, actor/time, and before/after explanation or band changes; acceptance of a request is not completion. Recalculation history and audit details remain tenant-scoped server capabilities. Rule-set administration, activation, rollback, and eligibility policy are not client-side authorization controls.
Phase 10 remains a pilot display contract until the API supplies stable versioned rule metadata, reproducible input lineage, complete explanation payloads, explicit eligibility reasons, and audited recalculation results. Browser smoke coverage should include score/band disagreement with eligibility, suppression precedence, stale/uncertain rendering, version changes, partial recalculation, and cross-tenant non-disclosure.
## Remaining limitations
The static client has no client-side crawler, scanner, contact extractor, enrichment scheduler, outreach integration, availability provider, or SSE delivery. It can display server-provided Phase 9 observations, but production still requires server-side official-site scoping, SSRF/DNS-rebinding/redirect controls, hard extraction/page/byte/time/candidate budgets, durable history/cache isolation and retention/deletion, abuse/rate controls, suppression regression tests, and authenticated provenance/audit coverage. For domain intelligence, display registrable-domain/PSL version and unresolved reasons, DNS status and freshness/TTL (not “available”), independent MX/NS/TXT uncertainty, and association confidence with explainable evidence. Never auto-attach candidate domains or treat `nxdomain`/`no_data` as availability. CSV preview is capped for display and is not an import workflow.
functionrenderContactExtractionPanel(p){constpanel=$('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>`;}
functionrenderExtractedContactsMarkup(payload){constcontacts=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>';}
asyncfunctionloadContactExtraction(id,{extract=false}={}){conststate=$('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{constscan=selectedDetail?.website_scan||selectedDetail?.websiteScan||{};constpayload={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};constresult=awaitjsonRequest(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;});}}
functionrenderScoreConfig(payload){constpanel=$('scoreRulesPanel');if(!panel)return;construles=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>`;}
functionrenderScoreDistribution(payload){constpanel=$('scoreDistributionPanel');if(!panel)return;constbuckets=payloadItems(payload,['distribution','buckets','items']);constbandMap=payload?.bands&&typeofpayload.bands==='object'&&!Array.isArray(payload.bands)?payload.bands:{};constsummary=payload?.summary||payload||{};constfallback=[['High priority',summary.high_priority??bandMap.high??0],['Medium priority',bandMap.medium??0],['Low priority',bandMap.low??0],['Ineligible',bandMap.ineligible??0]];constrows=buckets.length?buckets.map(item=>[scoreValue(item,['label','band','name'],'Score band'),scoreValue(item,['count','total','value'],0)]):fallback;consttotal=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>`;}
asyncfunctionupdateScoreRule(id,currentPoints){if(!canEditScores()||!id)return;constvalue=window.prompt('Points for this rule (0–100):',currentPoints);if(value===null)return;constpoints=Number(value);if(!Number.isInteger(points)||points<0||points>100){window.alert('Enter a whole number from 0 to 100.');return;}try{awaitjsonRequest(`/api/v1/score-rules/${encodeURIComponent(id)}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({points})});awaitloadScorePanels();}catch(error){if(error.message!=='unauthorized')window.alert(error.message||'Unable to update score rule.');}}
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=>{constitem=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>`;
}
asyncfunctionrecalculateScore(){constbutton=$('recalculateScoreBtn'),messageEl=$('scoreMessage');if(!button||!selectedId)return;button.disabled=true;if(messageEl)messageEl.textContent='Recalculation requested…';try{constresult=awaitjsonRequest(`/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;}}
functionrenderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}consth=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>`;}
asyncfunctionlogin(event){event.preventDefault();constform=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(newFormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{constres=awaitfetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});constbody=awaitres.json().catch(()=>({}));if(!res.ok)thrownewError(body.error||'Invalid email or password.');awaitbootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
asyncfunctionlogout(){try{awaitfetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
asyncfunctionbootstrap(){try{constres=awaitfetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)thrownewError('Could not verify session.');constuser=awaitres.json();showDashboard(user.user||user);awaitloadData();awaitloadJobs();awaitloadSources();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
asyncfunctionbootstrap(){try{constres=awaitfetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)thrownewError('Could not verify session.');constuser=awaitres.json();showDashboard(user.user||user);awaitloadData();awaitloadJobs();awaitloadSources();awaitloadScorePanels();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
<articleclass="metric-card"><divclass="metric-icon blue">◷</div><div><p>Freshness under 7d</p><h2id="metricFresh">0%</h2><spanclass="trend neutral">● Evidence coverage</span></div></article>
</section>
<sectionclass="score-overview"aria-label="Score overview"><articleclass="panel score-distribution-panel"id="scoreDistributionPanel"data-smoke="score-distribution"><divclass="detail-loading"aria-live="polite">Sign in to load score distribution…</div></article></section>
<sectionclass="score-rules-section"id="scoreRules"aria-labelledby="scoreRulesTitle"><articleclass="panel"id="scoreRulesPanel"data-smoke="score-rules"><divclass="detail-loading"aria-live="polite">Sign in to load score rules…</div></article></section>
,['Contact extraction safety and states',()=>js.includes('approved public business pages only')&&js.includes('SMTP')&&js.includes('outreach')&&js.includes('Loading public contacts')&&js.includes('No public contacts found')&&js.includes('Contact extraction failed')]
,['Score breakdown and recalculation contract',()=>!!d.querySelector('[data-smoke="score-breakdown"]')&&js.includes('Score breakdown')&&js.includes('Total score')&&js.includes('Priority')&&js.includes('Eligibility')&&js.includes('Score version')&&js.includes('/score/recalculate')&&js.includes('recalculateScoreBtn')]
,['Score rules configuration and permission affordance',()=>!!d.querySelector('[data-smoke="score-rules"]')&&!!d.querySelector('#scoreRules')&&js.includes('/api/v1/score-rules')&&js.includes('enabled')&&js.includes('points')&&js.includes('Read-only for this role')&&js.includes('canEditScores')]
,['Score distribution summary and safe states',()=>!!d.querySelector('[data-smoke="score-distribution"]')&&js.includes('/scoring/summary')&&js.includes('Loading score distribution')&&js.includes('Unable to load score distribution')&&js.includes('tenant-scoped')&&js.includes('do not override suppression')]
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.