@@ -294,6 +297,19 @@ class BrowserOnlyAppTests(unittest.TestCase):
saved=server.db().execute("SELECT ai_promotion_id FROM prospects WHERE id=1").fetchone()
self.assertEqual(saved[0],promotion["id"])
@patch("api.server.ai_assess_prospect",return_value={"ai_probability":88,"ai_recommendation":"contact","ai_reasoning":"Active business with a broken site."})
conn=server.db();conn.execute("INSERT INTO users(organization_id,email,name,password_hash,role,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",(1,"reviewer@example.com","Reviewer","","reviewer",1,server.now(),server.now()));conn.commit();user_id=conn.execute("SELECT id FROM users WHERE email='reviewer@example.com'").fetchone()[0];conn.close()
status,note=self.request("POST","/api/prospects/1/notes",{"body":"Call after reviewing the website."});self.assertEqual(status,201)
@@ -24,7 +24,7 @@ function prospectTable(){const rows=prospectRows();return `<div class="table-wra
functionrenderDashboard(){consttotal=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>effectiveScore(p)>=75).length,withEmail=prospects.filter(p=>p.emails).length;conststrongest=[...prospects].sort((a,b)=>effectiveScore(b)-effectiveScore(a)).slice(0,5);$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${strongest.map(p=>{constscore=effectiveScore(p);return`<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(score)}">${score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}${p.ai_checked_at?' · AI assessed':''}</small></span><span>›</span></button>`}).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
functionrenderDiscover(){$('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
functionrenderProspects(){$('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`<article class="panel"><div class="toolbar prospect-filter-row"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectScoreBand" aria-label="Filter by potential"><option value="all">All potential</option><option value="hot">Hot (85+)</option><option value="high">High (70–84)</option><option value="medium">Medium (45–69)</option><option value="low">Low (0–44)</option></select><select id="prospectWebsiteFilter" aria-label="Filter by website"><option value="all">Any website</option><option value="missing">No website</option><option value="healthy">Healthy website</option><option value="broken">Broken / unreachable</option></select><select id="prospectEmailFilter" aria-label="Filter by email"><option value="all">Any email</option><option value="has_email">Has public email</option><option value="no_email">No public email</option></select><select id="prospectReviewFilter" aria-label="Filter by review status"><option value="all">Any review status</option><option value="pending">In review</option><option value="approved">Accepted</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><p class="list-hint">Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message. Review status is changed inside the prospect drawer.</p><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter}
functionrenderRuns(){$('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)}
functionsuggestedOffer(p){if(p.ai_offer)returnp.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
functionrenderWebsiteCandidates(items,id){constsection=$('websiteCandidatesPanel');if(!section)return;constrows=Array.isArray(items)?items:[];section.innerHTML=`<h3>Possible official websites</h3><p class="muted">Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.</p><button class="secondary" data-find-websites="${id}">Search again</button>${rows.length?`<div class="candidate-list">${rows.map(c=>{constevidence=candidateEvidence(c);conststatus=c.status||'candidate';return`<div class="candidate-card"><div><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.domain)} ↗</a><span class="badge ${status==='accepted'?'good':status==='rejected'?'warn':'info'}">${esc(status)}</span></div><p>Confidence <b>${Number(c.confidence||0)}%</b></p>${evidence.length?`<ul>${evidence.map(item=>`<li>${esc(item)}</li>`).join('')}</ul>`:'<p class="muted">No evidence returned.</p>'}${status==='candidate'?`<div class="candidate-actions"><button class="primary" data-accept-website="${c.id}" data-prospect-id="${id}">Accept domain</button><button class="secondary" data-reject-website="${c.id}" data-prospect-id="${id}">Reject</button></div>`:''}</div>`}).join('')}</div>`:'<p class="muted">No plausible official website candidates found yet.</p>'}`}
functionaddWebsitePanel(id){constbody=$('drawerBody');constp=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;constsection=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`<h3>Find a possible website</h3><p>Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.</p><button class="secondary" data-find-websites="${p.id}">Find possible websites</button><p class="muted">Requires a tested AI provider in Settings.</p>`;constactions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);elsebody.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})}
asyncfunctionassessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{constd=awaitapi(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});constindex=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id);if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){constpanel=$('aiAssessmentPanel');if(panel){constnotice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
functionauthShell(title,setup){constroot=$('authRoot')||document.body.appendChild(Object.assign(document.createElement('div'),{id:'authRoot'}));root.innerHTML=`<div class="auth-card"><div class="auth-brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Private growth workspace</small></div></div><span class="eyebrow">${setup?'FIRST-TIME SETUP':'SECURE SIGN IN'}</span><h1>${title}</h1><p>${setup?'Create the workspace owner account. Existing catalogue data will be kept in this workspace.':'Sign in to continue to your prospect workspace.'}</p><form id="${setup?'authSetupForm':'authLoginForm'}"><label>Email<input id="authEmail" type="email" autocomplete="email" required placeholder="you@example.com"></label>${setup?`<label>Your name<input id="authName" required placeholder="Workspace owner"></label><label>Workspace name<input id="authWorkspace" required value="ProspectOS workspace"></label>`:''}<label>Password<input id="authPassword" type="password" autocomplete="${setup?'new-password':'current-password'}" required minlength="8" placeholder="At least 8 characters"></label><button class="primary" type="submit">${setup?'Create workspace':'Sign in'} <span>→</span></button><div id="authMessage" class="notice"></div></form></div>`;root.hidden=false;document.querySelector('.app-shell')?.setAttribute('aria-hidden','true');document.querySelector('.app-shell')?.classList.add('auth-hidden');$('detailDrawer')?.classList.remove('open')}
functionrenderAuth(status={}){authShell(status.setup_required?'Create your workspace':'Welcome back',Boolean(status.setup_required));constnav=$('nav');if(nav)nav.setAttribute('aria-hidden','true')}
<body><divclass="app-shell"><asideclass="sidebar"><divclass="brand"><spanclass="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><navid="nav"><ahref="#dashboard"data-route="dashboard"><span>⌂</span>Dashboard</a><ahref="#discover"data-route="discover"><span>⌁</span>Discover</a><ahref="#prospects"data-route="prospects"><span>▦</span>Prospects</a><ahref="#runs"data-route="runs"><span>◷</span>Runs</a><ahref="#settings"data-route="settings"><span>⚙</span>Settings</a></nav><divclass="sidebar-foot"><spanclass="status-dot"id="sourceDot"></span><div><bid="sourceLabel">Checking worker</b><smallid="sourceSub">Connecting…</small></div></div></aside><mainclass="main"><headerclass="topbar"><div><spanclass="crumb">Workspace</span><spanclass="slash">/</span><bid="pageName">Dashboard</b></div><divclass="top-actions"><spanid="updated">—</span><buttonclass="icon-btn"id="refreshAll"title="Refresh">↻</button></div></header><divid="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><asideid="detailDrawer"class="detail-drawer"aria-hidden="true"><divclass="drawer-head"><div><spanclass="eyebrow">PROSPECT DETAIL</span><h2id="drawerName">Prospect</h2></div><buttonclass="icon-btn"id="closeDrawer">×</button></div><divid="drawerBody"></div></aside><dialogid="messageDialog"><formmethod="dialog"id="messageForm"><divclass="dialog-head"><div><labelclass="eyebrow">AI ASSISTED DRAFT</label><h2id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><buttonclass="icon-btn"value="cancel">×</button></div><labelfor="offerInput">Your offer or instruction</label><textareaid="offerInput"rows="4"placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><divid="messageStatus"class="notice"></div><buttonclass="primary"id="generateMessage"type="button">Generate subject and email</button><labelfor="subjectOutput">Subject</label><inputid="subjectOutput"><labelfor="bodyOutput">Email draft</label><textareaid="bodyOutput"rows="12"></textarea><divclass="dialog-actions"><buttonclass="secondary"id="copyMessage"type="button">Copy draft</button><buttonclass="secondary"value="cancel">Close</button></div></form></dialog><scriptsrc="/app.js?v=dashboard-fix-v1"></script></body></html>
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.