add reversible prospect deduplication

This commit is contained in:
Marco0300
2026-09-03 08:46:22 +02:00
parent 46cc1f6182
commit 25ee7931ab
14 changed files with 290 additions and 13 deletions
+33 -2
View File
@@ -25,7 +25,38 @@
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>`;
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><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><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>`:''}`;}
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><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><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>`:''}`;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);}}
@@ -81,7 +112,7 @@
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();}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==='retryDetailBtn'&&selectedId)loadDetail(selectedId);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);});
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();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();
})();