← back to Dw Chat Analyzer
server.js
92 lines
#!/usr/bin/env node
'use strict';
/*
* dw-chat-analyzer — Dashboard + Leads + export over realestate.dw_chats
* (Zendesk Chat traffic for Designer Wallcoverings). Node http + pg (parameterized).
* Basic Auth admin / DW2024! (override VIEWER_USER / VIEWER_PASS).
*/
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const { Pool } = require('pg');
const PORT = Number(process.env.PORT || 0), HOST = process.env.HOST || '127.0.0.1';
const USER = process.env.VIEWER_USER || 'admin', PASS = process.env.VIEWER_PASS || 'DW2024!';
const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'realestate', max: 6 });
pool.on('error', (e) => console.error('pg pool error', e));
const T = 'dw_chats';
const SORTABLE = new Set(['started_at','duration','department','rating','is_lead','visitor_country','landing_page','ref_terms']);
const FILTERS = ['department','is_lead','lead_tier','ref_engine','visitor_country'];
const SEARCH = ['visitor_intent','ref_terms','landing_page','visitor_name','visitor_city','comment','tags'];
const eq = (a,b)=>{const x=Buffer.from(a),y=Buffer.from(b);return x.length===y.length&&crypto.timingSafeEqual(x,y);};
function authed(req){const h=req.headers.authorization||'';if(!h.startsWith('Basic '))return false;
const [u,p]=Buffer.from(h.slice(6),'base64').toString('utf8').split(':');return eq(u||'',USER)&&eq(p||'',PASS);}
function where(qs){const c=[],p=[];
for(const col of FILTERS){const v=qs.get(col);if(v!=null&&v!==''){p.push(col==='is_lead'?v==='true':v);c.push(`${col}=$${p.length}`);}}
if(qs.get('new')==='1') c.push(`started_at > now() - interval '48 hours'`);
const q=(qs.get('q')||'').trim();
if(q){p.push(`%${q}%`);const i=p.length;c.push('('+SEARCH.map(s=>`${s} ILIKE $${i}`).join(' OR ')+')');}
return {w:c.length?'WHERE '+c.join(' AND '):'',p};}
async function apiChats(qs){
const {w,p}=where(qs);
let sort=qs.get('sort')||'started_at'; if(!SORTABLE.has(sort))sort='started_at';
const dir=(qs.get('dir')||'desc').toLowerCase()==='asc'?'ASC':'DESC';
const limit=Math.min(Math.max(parseInt(qs.get('limit'),10)||50,1),200), page=Math.max(parseInt(qs.get('page'),10)||1,1);
const total=Number((await pool.query(`SELECT count(*)::bigint n FROM ${T} ${w}`,p)).rows[0].n);
const rows=(await pool.query(
`SELECT id,started_at,department,agents,rating,is_lead,lead_tier,ref_engine,ref_terms,landing_page,page_count,
visitor_name,visitor_city,visitor_region,visitor_country,visitor_intent,comment,tags,duration,
zendesk_link, (started_at > now() - interval '48 hours') AS is_new
FROM ${T} ${w} ORDER BY ${sort} ${dir} NULLS LAST, started_at DESC
LIMIT $${p.length+1} OFFSET $${p.length+2}`,[...p,limit,(page-1)*limit])).rows;
return {total,page,limit,sort,dir,rows};
}
let sc=null,sa=0;
async function apiStats(){
if(sc&&Date.now()-sa<60000)return sc;
const one=async(q)=>(await pool.query(q)).rows;
const h=(await pool.query(`SELECT count(*)::int total,
count(*) filter (where lead_tier='HOT')::int hot,
count(*) filter (where lead_tier='WARM')::int warm,
count(*) filter (where lead_tier='HOT' and started_at > now()-interval '48 hours')::int hot_recent,
count(*) filter (where started_at > now()-interval '48 hours')::int newc FROM ${T}`)).rows[0];
const tot=h.total, leads=h.hot, hot=h.hot, warm=h.warm, hot_recent=h.hot_recent, newc=h.newc;
const terms=await one(`SELECT ref_terms v, count(*)::int n FROM ${T} WHERE ref_terms<>'' GROUP BY 1 ORDER BY n DESC LIMIT 12`);
const land=await one(`SELECT regexp_replace(landing_page,'^https?://[^/]+','') v, count(*)::int n FROM ${T} WHERE landing_page<>'' GROUP BY 1 ORDER BY n DESC LIMIT 12`);
const eng=await one(`SELECT coalesce(nullif(ref_engine,''),'(direct/none)') v, count(*)::int n FROM ${T} GROUP BY 1 ORDER BY n DESC LIMIT 8`);
const dept=await one(`SELECT coalesce(nullif(department,''),'(none)') v, count(*)::int n FROM ${T} GROUP BY 1 ORDER BY n DESC LIMIT 8`);
const rating=await one(`SELECT coalesce(nullif(rating,''),'unrated') v, count(*)::int n FROM ${T} GROUP BY 1 ORDER BY n DESC`);
const geo=await one(`SELECT coalesce(nullif(visitor_country,''),'?') v, count(*)::int n FROM ${T} GROUP BY 1 ORDER BY n DESC LIMIT 10`);
const byday=await one(`SELECT to_char(date_trunc('day',started_at),'YYYY-MM-DD') v, count(*)::int n FROM ${T} WHERE started_at IS NOT NULL GROUP BY 1 ORDER BY 1 DESC LIMIT 30`);
return sc={total:tot,leads,hot,warm,hot_recent,newc,terms,land,eng,dept,rating,geo,byday}, sa=Date.now(), sc;
}
function send(res,code,body,type='application/json'){res.writeHead(code,{'Content-Type':type,'Cache-Control':'no-store'});
res.end(typeof body==='string'||Buffer.isBuffer(body)?body:JSON.stringify(body));}
const server=http.createServer(async(req,res)=>{
if(!authed(req)){res.writeHead(401,{'WWW-Authenticate':'Basic realm="dw-chat"'});return res.end('Auth required');}
const url=new URL(req.url,'http://x');
try{
if(url.pathname==='/'||url.pathname==='/index.html')return send(res,200,fs.readFileSync(path.join(__dirname,'public','index.html')),'text/html; charset=utf-8');
if(url.pathname==='/api/stats')return send(res,200,await apiStats());
if(url.pathname==='/api/chats')return send(res,200,await apiChats(url.searchParams));
// FileMaker pulls this (Insert from URL) to pop an on-screen dialog for new HOT chats.
if(url.pathname==='/api/new-hot'){
const since=url.searchParams.get('since')||new Date(Date.now()-864e5).toISOString();
const r=await pool.query(
`SELECT id, started_at, coalesce(nullif(visitor_email,''),nullif(visitor_phone,''),'(contact on file)') contact,
nullif(concat_ws(', ',nullif(visitor_city,''),nullif(visitor_region,''),nullif(visitor_country,'')),'') location,
regexp_replace(landing_page,'^https?://[^/]+','') landing, zendesk_link
FROM ${T} WHERE lead_tier='HOT' AND started_at > $1::timestamptz
ORDER BY started_at DESC LIMIT 25`,[since]);
return send(res,200,{count:r.rows.length, latest:r.rows[0]?r.rows[0].started_at:since, chats:r.rows});
}
if(url.pathname==='/healthz')return send(res,200,{ok:true});
return send(res,404,{error:'not found'});
}catch(e){console.error(e);return send(res,500,{error:String(e.message||e)});}
});
server.listen(PORT,HOST,()=>console.log(`dw-chat-analyzer live: http://${HOST}:${server.address().port} (login ${USER})`));