diff --git a/apps/web/app.js b/apps/web/app.js index c02557a..4c6fe43 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -162,7 +162,7 @@ async function approveOutreachDraft(id){if(!id||!window.confirm('Approve this draft for recordkeeping? Approval does not send a message.'))return;try{await jsonRequest(`/api/v1/outreach/drafts/${encodeURIComponent(id)}/approve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,send:false,autonomous_action:false})});await loadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState('error',error.message);}} function renderProviderPolicy(payload){const panel=$('providerPolicyPanel');if(!panel)return;const items=payloadItems(payload,['providers','items','policies']),status=outreachState(payload?.status||payload?.state||'');if(['error','auth-required','suppressed','blocked','unknown'].includes(status)){panel.innerHTML=`
${esc(outreachLabel(status))} provider policy

${esc(payload?.message||payload?.reason||'No provider status was established. No secrets are displayed.')}

`;return;}if(!payload){panel.innerHTML='
Provider policy unavailable

No provider configuration was returned. No secrets are displayed.

';return;}panel.innerHTML=items.length?items.map(provider=>{const configured=Boolean(provider.configured??provider.is_configured),enabled=Boolean(provider.enabled??provider.active),providerStatus=provider.status||(!configured?'not-configured':enabled?'enabled':'disabled');return `
${esc(provider.name||provider.provider_id||provider.id||'Approved provider')}${esc(provider.model||provider.version||'Version not returned')}
${esc(outreachLabel(providerStatus))}
Configured
${configured?'Yes':'No'}
Enabled
${enabled?'Yes':'No'}
Policy
${esc(provider.policy_version||'Unknown')}
Secrets
Not displayed
`;}).join(''):`
Outreach provider not configured

Drafting and sending are unavailable until an approved provider is configured. No secrets are displayed.

`;} async function loadProviderPolicy(){const panel=$('providerPolicyPanel');if(!panel)return;panel.innerHTML='
Loading provider policy…
';try{const payload=await jsonRequest('/api/v1/outreach/provider-config');renderProviderPolicy(payload);}catch(error){if(error.message!=='unauthorized')renderProviderPolicy({status:error.message.toLowerCase().includes('auth')?'auth-required':'error',message:error.message,providers:[]});}} - 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=`

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain||'no detected website')}

${esc(review)}
Fit score${s}/ 100
${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence
Loading score breakdown…

Pipeline stage

Contacts ${contacts.length}

${listItems(contacts,'No contacts added.','email')}

Loading public contacts…
Loading website scan…
Loading domain intelligence…

AI ASSISTANCE

Evidence-grounded suggestion

AI suggestions summarize workspace evidence and citations only. Review and approve explicitly; No autonomous action or outreach is taken.

Loading AI assistance…

OUTREACH PREPARATION

Evidence-grounded outreach draft

Draft-only preparation. Review the recipient and citations before approval. No message will be sent from this screen.

Loading outreach drafts…

Sending is disabled by policy; approval never sends a message.

Domains & websites

${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}

Evidence timeline

${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}

`).join(''):''}

Notes ${notes.length}

${listItems(notes,'No notes added.','body')}

Review status

${esc(review)}

${st!=='suppressed'?'':''}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);loadAiAssistance(p.id);loadOutreachDrafts(p.id);renderDedupPanel();} + 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=`

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain||'no detected website')}

${esc(review)}
Fit score${s}/ 100
${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence
Loading score breakdown…

Pipeline stage

Contacts ${contacts.length}

${listItems(contacts,'No contacts added.','email')}

Loading public contacts…
Loading website scan…
Loading domain intelligence…

AI ASSISTANCE

Evidence-grounded suggestion

AI suggestions summarize workspace evidence and citations only. Review and approve explicitly; No autonomous action or outreach is taken.

Loading AI assistance…

OUTREACH PREPARATION

Evidence-grounded outreach draft

Draft-only preparation. Review the recipient and citations before approval. No message will be sent from this screen.

Loading outreach drafts…

Sending is disabled by policy; approval never sends a message.

Domains & websites

${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}

Evidence timeline

${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}

`).join(''):''}

Notes ${notes.length}

${listItems(notes,'No notes added.','body')}

Review status

${esc(review)}

${st!=='suppressed'?'':''}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);loadAiAssistance(p.id);loadOutreachDrafts(p.id);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; @@ -202,16 +202,16 @@ 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';}}} // Phase 12 CRM surfaces: all actions are authenticated internal records; no send/outreach capability. let crmPipelineItems = [], crmListMode = false, selectedSuppressionIds = new Set(); - const crmStages = ['new','qualified','review','contacted','meeting','won','lost','suppressed']; + const crmStages = ['new','contacted','qualified','proposal','negotiation','won','lost']; const crmLabel = value => String(value || 'unknown').replaceAll('_',' ').replace(/\\b\\w/g, c => c.toUpperCase()); const crmArray = (payload, keys=[]) => payload && Array.isArray(payload) ? payload : (keys.map(k => payload?.[k]).find(Array.isArray) || []); const crmStage = p => String(p.pipeline_stage || p.stage || p.pipeline?.stage || (Array.isArray(p.pipeline) ? p.pipeline.at(-1)?.stage : '') || 'new').toLowerCase(); function crmMessage(text, error=false){const el=$('crmMessage');if(el){el.textContent=text||'';el.className=`crm-message${error?' error':''}`;}} function renderPipeline(){const board=$('pipelineBoard');if(!board)return;if(!crmPipelineItems.length){board.innerHTML='
No pipeline records returned by the workspace.
';return;}const card=p=>{const st=statusOf(p), suppressed=st==='suppressed'||p.suppressed===true;return `
${suppressed?'

Suppressed · no outreach

':st==='review'?'

Needs review before outreach

':''}
`;};if(crmListMode){board.className='pipeline-board pipeline-list-view';board.innerHTML=`
${crmPipelineItems.map(card).join('')}
`;}else{board.className='pipeline-board';board.innerHTML=crmStages.map(stage=>{const items=crmPipelineItems.filter(p=>crmStage(p)===stage);return `

${crmLabel(stage)}

${items.length}
${items.length?items.map(card).join(''):'

No prospects

'}
`;}).join('');}} async function loadCrmPipeline(){const board=$('pipelineBoard');if(board)board.innerHTML='
Loading CRM pipeline…
';try{const [businessPayload,pipelinePayload]=await Promise.all([jsonRequest('/api/v1/businesses?page=1&page_size=100'),jsonRequest('/api/v1/pipeline-entries')]);const businesses=crmArray(businessPayload,['items','businesses','prospects']),entries=crmArray(pipelinePayload,['items','entries','pipeline']);const byId=new Map(businesses.map(p=>[String(p.id),p]));crmPipelineItems=entries.map(entry=>({...byId.get(String(entry.business_id||entry.prospect_id))||{},...entry,id:entry.business_id||entry.prospect_id||entry.id}));if(!crmPipelineItems.length)crmPipelineItems=businesses;renderPipeline();crmMessage('');}catch(error){if(error.message!=='unauthorized'){if(board)board.innerHTML=``;crmMessage(error.message,true);}}} - async function saveCrmStage(id){const item=crmPipelineItems.find(p=>String(p.id)===String(id)),select=$(`crm-stage-${id}`);if(!item||!select)return;const next=select.value;if(next===crmStage(item))return;const suppressed=statusOf(item)==='suppressed';if(suppressed&&next!=='suppressed'){window.alert('Suppressed prospects must remain in Suppressed. Remove the suppression rule first if policy allows.');select.value='suppressed';return;}if(!window.confirm(`Move ${item.name||`Prospect ${id}`} to ${crmLabel(next)}? This records an internal stage transition only; no outreach will be sent.`)){select.value=crmStage(item);return;}try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/pipeline`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage:next,reason:'CRM operator transition',outreach:false})});crmMessage('Stage transition saved.');await Promise.all([loadCrmPipeline(),loadData()]);}catch(error){if(error.message!=='unauthorized')crmMessage(error.message,true);select.value=crmStage(item);}} - async function loadInteractions(id){const state=$('interactionState');if(!state)return;state.innerHTML='
Loading interaction timeline…
';try{const payload=await jsonRequest(`/api/v1/interactions?business_id=${encodeURIComponent(id)}`),items=crmArray(payload,['interactions','items','timeline','events']);state.innerHTML=`

TIMELINE

${esc((crmPipelineItems.find(p=>String(p.id)===String(id))||selectedDetail||{}).name||`Prospect ${id}`)}

${items.length} record${items.length===1?'':'s'}
${items.length?`
    ${items.map(item=>`
  1. ${esc(item.type||item.kind||'Interaction')}${esc(crmLabel(item.outcome||'No outcome'))}

    ${esc(item.summary||item.notes||item.body||'No summary')}

    ${esc(item.follow_up_at?`Follow-up ${item.follow_up_at} · `:'')}${esc(item.created_at||item.occurred_at||'Time unavailable')}
  2. `).join('')}
`:'

No interactions recorded yet.

'}`;state.dataset.businessId=id;}catch(error){if(error.message!=='unauthorized')state.innerHTML=``;}} - async function saveInteraction(event){event.preventDefault();if(!selectedId){message('interactionMessage','Select a prospect in the pipeline first.',true);return;}const form=event.currentTarget,data=Object.fromEntries(new FormData(form).entries());if(!data.summary.trim()){message('interactionMessage','Add an internal summary before saving.',true);return;}const button=form.querySelector('button[type="submit"]');button.disabled=true;try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/interactions`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...data,outreach:false})});message('interactionMessage','Interaction saved. No message was sent.');form.reset();await loadInteractions(selectedId);}catch(error){if(error.message!=='unauthorized')message('interactionMessage',error.message,true);}finally{button.disabled=false;}} + async function saveCrmStage(id){const item=crmPipelineItems.find(p=>String(p.id)===String(id)),select=$(`crm-stage-${id}`);if(!item||!select)return;const next=select.value;if(!crmStages.includes(next)){select.value=crmStage(item);crmMessage('Choose a valid pipeline stage.',true);return;}if(next===crmStage(item))return;if(statusOf(item)==='suppressed'){window.alert('Suppressed prospects remain unavailable for contact. Remove the suppression rule first if policy allows.');select.value=crmStage(item);return;}if(!window.confirm(`Move ${item.name||`Prospect ${id}`} to ${crmLabel(next)}? This records an internal stage transition only; no outreach will be sent.`)){select.value=crmStage(item);return;}try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/pipeline`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage:next,reason:'CRM operator transition',outreach:false})});crmMessage('Stage transition saved.');await Promise.all([loadCrmPipeline(),loadData()]);}catch(error){if(error.message!=='unauthorized')crmMessage(error.message,true);select.value=crmStage(item);}} + async function loadInteractions(id){const state=$('interactionState');if(!state)return;state.innerHTML='
Loading interaction timeline…
';try{const payload=await jsonRequest(`/api/v1/interactions?business_id=${encodeURIComponent(id)}`),items=crmArray(payload,['interactions','items','timeline','events']);state.innerHTML=`

TIMELINE

${esc((crmPipelineItems.find(p=>String(p.id)===String(id))||selectedDetail||{}).name||`Prospect ${id}`)}

${items.length} record${items.length===1?'':'s'}
${items.length?`
    ${items.map(item=>`
  1. ${esc(item.kind||'Interaction')}${esc(crmLabel(item.outcome||'No outcome'))}

    ${esc(item.body||item.notes||'No body')}

    ${esc(item.follow_up_at?`Follow-up ${item.follow_up_at} · `:'')}${esc(item.created_at||item.occurred_at||'Time unavailable')}
  2. `).join('')}
`:'

No interactions recorded yet.

'}`;state.dataset.businessId=id;}catch(error){if(error.message!=='unauthorized')state.innerHTML=``;}} + async function saveInteraction(event){event.preventDefault();if(!selectedId){message('interactionMessage','Select a prospect in the pipeline first.',true);return;}const form=event.currentTarget,data=Object.fromEntries(new FormData(form).entries());if(!data.body.trim()){message('interactionMessage','Add an internal body before saving.',true);return;}const button=form.querySelector('button[type="submit"]');button.disabled=true;try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/interactions`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:data.kind,body:data.body,outcome:data.outcome||'other',follow_up_at:data.follow_up_at||null,outreach:false})});message('interactionMessage','Interaction saved. No message was sent.');form.reset();await loadInteractions(selectedId);}catch(error){if(error.message!=='unauthorized')message('interactionMessage',error.message,true);}finally{button.disabled=false;}} let suppressions=[]; function renderSuppressions(){const state=$('suppressionState');if(!state)return;if(!suppressions.length){state.innerHTML='
No suppression rules returned by the workspace.
';return;}state.innerHTML=`
${suppressions.map(item=>`
`).join('')}
`;updateSuppressionSelection();} function updateSuppressionSelection(){const count=selectedSuppressionIds.size;const btn=$('bulkReviewSuppressionsBtn');if(btn){btn.disabled=!count;btn.textContent=count?`Review selected (${count})`:'Review selected';}const all=$('selectAllSuppressions');if(all)all.checked=Boolean(suppressions.length&&count===suppressions.length);} diff --git a/apps/web/asset-manifest.json b/apps/web/asset-manifest.json index c32e6b4..6b927f3 100644 --- a/apps/web/asset-manifest.json +++ b/apps/web/asset-manifest.json @@ -1,6 +1,6 @@ { "schema": 1, - "version": "phase-16", + "version": "phase-17", "entrypoints": [ "config.js", "app.js", @@ -14,9 +14,9 @@ ], "integrity": { "config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6", - "app.js": "sha256-c296037e7dfdb11543a18764f91cef8398f9c41c37805dfcd377acb4e089fd82", + "app.js": "sha256-cf0d8b00773877c09f03707030e3e8113b3f34ad389054e0a97ecb1dbddd139f", "styles.css": "sha256-56ca598c8e8cbbcb91d81013c6a498f75193de79e33dd2d8d117df836fc43d57", - "index.html": "sha256-c25a164ebf59bc923f89dd5af17af4124fc0daf503ea52f34ff36ed52b3d5b4a", + "index.html": "sha256-f3c9544c07c08c5248abafaf8075551ee641704f9cb3d516449188f74bf86644", "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" diff --git a/apps/web/index.html b/apps/web/index.html index 7224ceb..805d41d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -65,7 +65,7 @@
Sign in to load score rules…

PIPELINE

Prospect explorer

-
+

OPERATOR QUEUE

Review queue 0

Human decision required
Loading review queue…
0 selected
Showing 0 prospects High fit Needs review
@@ -108,7 +108,7 @@

RELATIONSHIP HISTORY

Interactions & follow-ups

Capture outcomes and next steps without contacting anyone.

-
Select a prospect to load interactions.

ADD RECORD

Log an interaction

Internal only

+
Select a prospect to load interactions.

ADD RECORD

Log an interaction

Internal only

REPORTING

CRM reports

Tenant-scoped pipeline, outcomes, and activity summaries from the API.

diff --git a/apps/web/scripts/final-acceptance.mjs b/apps/web/scripts/final-acceptance.mjs index 5276f06..36ec1c5 100644 --- a/apps/web/scripts/final-acceptance.mjs +++ b/apps/web/scripts/final-acceptance.mjs @@ -82,6 +82,17 @@ check('routes.contracts', 'all critical API route contracts are referenced by th check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest')); check('routes.no-arbitrary-target-fetch', 'browser does not fetch arbitrary target URLs directly', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js)); +const canonicalStages = ['new', 'contacted', 'qualified', 'proposal', 'negotiation', 'won', 'lost']; +const crmStagesMatch = js.match(/const crmStages = \[([^\]]+)\]/); +const crmStages = crmStagesMatch ? [...crmStagesMatch[1].matchAll(/["']([^"']+)["']/g)].map(match => match[1]) : []; +check('crm.stage-contract', 'CRM stage definitions exactly match the canonical pipeline', JSON.stringify(crmStages) === JSON.stringify(canonicalStages), `actual=${JSON.stringify(crmStages)}`); +const pipelineControl = js.match(/