← back to Approval Viewer

server.js

62 lines

'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = Number(process.env.PORT || 9788);
const decisionsPath = process.env.DECISIONS_PATH || path.join(__dirname, 'decisions.jsonl');
const demoItems = [
  { id:'TK-10409-A', title:'Publish campaign to social accounts', gate:'external publish', risk:'hard', owner:'Marketing', created_at:'2026-08-26T16:00:00.000Z', summary:'Would publish to selected social accounts. Review targets and copy first.' },
  { id:'TK-10409-B', title:'Deploy pending website change', gate:'production deploy', risk:'hard', owner:'Engineering', created_at:'2026-08-26T16:05:00.000Z', summary:'Would change the live site. Confirm tests, rollback, and timing.' },
  { id:'TK-10409-C', title:'Send campaign email', gate:'outbound email', risk:'hard', owner:'Marketing', created_at:'2026-08-26T16:10:00.000Z', summary:'Would contact a list. Confirm audience, consent, and message.' },
  { id:'TK-10409-D', title:'Run approval dry-run report', gate:'read-only', risk:'soft', owner:'Operations', created_at:'2026-08-26T16:15:00.000Z', summary:'Creates a local report only; no external side effects.' },
  { id:'TK-10409-E', title:'Update campaign draft copy', gate:'local draft', risk:'soft', owner:'Marketing', created_at:'2026-08-26T16:20:00.000Z', summary:'Local draft edit. Nothing is published until separately approved.' }
];
const queueDir = path.join(process.env.HOME || '/Users/macstudio3', '.claude', 'yolo-queue', 'pending-approval');
function queueItems(){
  try {
    return fs.readdirSync(queueDir).filter(n=>n.endsWith('.md')||n.endsWith('.json')).map(name=>{
      const itemPath=path.join(queueDir,name);
      const raw=fs.readFileSync(itemPath,'utf8');
      const stat=fs.statSync(itemPath);
      const heading=(raw.match(/^#\s+(.+)$/m)||[])[1] || name.replace(/\.[^.]+$/,'').replace(/[-_]+/g,' ');
      const gate=(raw.match(/(?:GATE|gate|approval(?: required)?)[^\n:]*[:\-]\s*([^\n]+)/i)||[])[1] || 'gated approval';
      return {id:'QUEUE-'+name,title:heading.slice(0,180),gate:gate.slice(0,180),risk:'hard',owner:'pending-approval',created_at:stat.birthtime.toISOString(),summary:raw.replace(/\s+/g,' ').slice(0,360),source:name};
    });
  } catch { return []; }
}
function items(){ const live=queueItems(); return live.length ? live : demoItems; }
function readDecisions(){ try{return fs.readFileSync(decisionsPath,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);}catch(_){return [];} }
function send(res,status,data,type='application/json'){res.writeHead(status,{'Content-Type':type});res.end(type==='application/json'?JSON.stringify(data):data);}
function body(req){return new Promise(resolve=>{let s='';req.on('data',c=>s+=c);req.on('end',()=>resolve(s));});}
function approvalRecord(id,note=''){
  return {id,action:'approve',approvalScope:'full',note:String(note).slice(0,500),at:new Date().toISOString(),writePerformed:false};
}
const server=http.createServer(async (req,res)=>{
  if(req.method==='GET' && req.url==='/favicon.ico') { res.writeHead(204); return res.end(); }
  if(req.method==='GET' && (req.url==='/'||req.url==='/index.html')) return send(res,200,fs.readFileSync(path.join(__dirname,'public/index.html'),'utf8'),'text/html; charset=utf-8');
  if(req.method==='GET' && req.url==='/api/items') return send(res,200,{items:items(),decisions:readDecisions(),writePerformed:false,source:queueItems().length?'pending-approval':'demo'});
  if(req.method==='POST' && req.url==='/api/decision'){
    let data; try{data=JSON.parse(await body(req));}catch(_){return send(res,400,{error:'invalid json'});}
    if(!data.id||!['approve','revise','block'].includes(data.action)) return send(res,400,{error:'id and action approve|revise|block required'});
    if(!items().some(i=>i.id===data.id)) return send(res,404,{error:'unknown item'});
    const rec=data.action==='approve'
      ? approvalRecord(data.id,data.note||'')
      : {id:data.id,action:data.action,note:String(data.note||'').slice(0,500),at:new Date().toISOString(),writePerformed:false};
    fs.appendFileSync(decisionsPath,JSON.stringify(rec)+'\n');
    return send(res,200,{ok:true,decision:rec,writePerformed:false});
  }
  if(req.method==='POST' && req.url==='/api/decisions/approve-all'){
    let data; try{data=JSON.parse(await body(req));}catch(_){return send(res,400,{error:'invalid json'});}
    if(!Array.isArray(data.ids)||!data.ids.length) return send(res,400,{error:'ids must be a non-empty array'});
    const known=new Set(items().map(i=>i.id));
    const ids=[...new Set(data.ids.map(String))];
    const unknown=ids.filter(id=>!known.has(id));
    if(unknown.length) return send(res,404,{error:'unknown item',ids:unknown});
    const records=ids.map(id=>approvalRecord(id,data.note||'Approved in bulk from the local approval viewer.'));
    fs.appendFileSync(decisionsPath,records.map(rec=>JSON.stringify(rec)).join('\n')+'\n');
    return send(res,200,{ok:true,decisions:records,count:records.length,writePerformed:false});
  }
  send(res,404,{error:'not found'});
});
server.listen(PORT,()=>console.log(`Approval Viewer listening on http://127.0.0.1:${PORT}`));