Traffic Desk: stop-and-refund, plus never let a banner dead-end

Three fixes from live testing of the Traffic Desk:

- /p/<id> with no page built now 302s to /join/<id> instead of returning
  raw JSON 404. Any /p/ link already on a banner, flyer, or in a DM must
  always land somewhere useful.
- The personal-page ad destination is only offered (client) and only
  accepted (server) once a page actually exists; otherwise the member is
  pointed at the Page Builder.
- Members can stop a running banner and get the unserved impressions back
  in their monthly balance. Counters are read before deactivation, since
  deactivating zeroes `remaining` and would look fully served.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 10:03:38 -05:00
parent f7276589e9
commit 931eb4336b
4 changed files with 130 additions and 15 deletions
+25 -2
View File
@@ -671,7 +671,13 @@ async function handleApi(req,res,pathname){
if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){
const pid=pathname.split('/')[2];
const rec=suitePages.load(pid);
if(!rec||!rec.copy)return json(res,404,{error:'No page here yet.'});
// No custom page built yet? NEVER dead-end — especially not with JSON. Any
// /p/<id> link may already be on a banner, a flyer, or in someone's DMs, so
// it must always land somewhere useful: the member's own invite page.
if(!rec||!rec.copy){
res.writeHead(302,securityHeaders({'Location':'/join/'+pid,'Cache-Control':'no-store'}));
return res.end();
}
const html=suitePages.render(rec);
// Member pages must ALWAYS be viewable inside a frame — the builder previews
// them, and members share them into contexts that embed. Never send
@@ -774,7 +780,11 @@ async function handleApi(req,res,pathname){
const st=suiteTraffic.status(e.d.id,e.d.level);
let live=[];
try{ live=await suiteTraffic.stats(st.campaigns.map(c=>c.adId)); }catch(err){}
return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live});
// Tell the UI whether a personal page actually exists, so it can't be
// offered as an ad destination before it's been built.
const pg=suitePages.load(e.d.id);
const hasPage=!!(pg&&pg.copy);
return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live,hasPage:hasPage});
}
if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
@@ -783,15 +793,28 @@ async function handleApi(req,res,pathname){
if(!suiteTraffic.configured())return json(res,503,{error:'The ad network bridge is warming up — try again shortly.'});
const b=await bodyJson(req)||{};
try{
const pgRec=suitePages.load(e.d.id);
const entry=await suiteTraffic.launch({
id:e.d.id, level:e.d.level, size:b.size, creative:b.creative,
impressions:b.impressions, target:b.target, angle:b.angle,
hasPage:!!(pgRec&&pgRec.copy),
name:String(b.name||'').slice(0,60)
});
return json(res,200,{campaign:entry,status:suiteTraffic.status(e.d.id,e.d.level)});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
const b=await bodyJson(req)||{};
try{
const r=await suiteTraffic.stop(e.d.id,Number(b.ad_id));
return json(res,200,{stopped:r,status:suiteTraffic.status(e.d.id,e.d.level)});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
if(req.method==='GET'&&pathname==='/api/public/suite-meters'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});