← back to Majilite Onboard

server.js

98 lines

#!/usr/bin/env node
/**
 * Majilite Metallic Specialties I — live onboarding viewer (zero-dependency).
 * Basic-auth admin/DW2024!. Serves the staged catalog + an SSE "import" stream
 * so you watch all 160 SKUs flow through the pipeline stages as they onboard.
 */
const http = require('http');
const fs = require('fs');
const path = require('path');

const ROOT = __dirname;
const PORT = process.env.PORT ? Number(process.env.PORT) : 0; // 0 = OS-assigned free port
const USER = process.env.BASIC_AUTH_USER || 'admin';
const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';

const products = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'data/products.json'), 'utf8'));
const collection = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'data/collection.json'), 'utf8'));

const MIME = { '.html':'text/html', '.js':'application/javascript', '.css':'text/css',
  '.json':'application/json', '.png':'image/png', '.jpg':'image/jpeg', '.svg':'image/svg+xml' };

function unauth(res){ res.writeHead(401,{'WWW-Authenticate':'Basic realm="majilite"'}); res.end('Auth required'); }
function ok(res,body,type='application/json'){ res.writeHead(200,{'Content-Type':type}); res.end(body); }

const server = http.createServer((req,res)=>{
  // Basic auth gate (Steve's standing viewer rule)
  const h = req.headers.authorization || '';
  const [u,p] = Buffer.from(h.split(' ')[1]||'', 'base64').toString().split(':');
  if (u!==USER || p!==PASS) return unauth(res);

  const url = new URL(req.url, 'http://x');
  const p0 = url.pathname;

  if (p0==='/api/collection') return ok(res, JSON.stringify(collection()));
  if (p0==='/api/products')   return ok(res, JSON.stringify(products()));

  if (p0==='/api/facets'){
    const all = products();
    const tally = (key)=>{ const m={}; for(const x of all){ const v=x[key]; if(v) m[v]=(m[v]||0)+1; } return m; };
    const patTally={}; for(const x of all){ patTally[x.pattern]=(patTally[x.pattern]||0)+1; }
    return ok(res, JSON.stringify({
      total: all.length,
      color_family: tally('color_family'),
      pattern: patTally,
      product_type: tally('product_type'),
      onboard_status: tally('onboard_status')
    }));
  }

  // SSE live-import stream: emits start, one 'item' per SKU (staged through 5 stages), then done.
  if (p0==='/api/import/stream'){
    const speed = Math.max(8, Math.min(400, Number(url.searchParams.get('speed'))||45)); // ms/item
    res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive'});
    const all = products();
    const STAGES = ['parsed','sku_assigned','spec_attached','image_linked','staged'];
    res.write(`event: start\ndata: ${JSON.stringify({total:all.length, stages:STAGES, collection:collection().collection})}\n\n`);
    let i=0;
    const timer = setInterval(()=>{
      if (i>=all.length){
        res.write(`event: done\ndata: ${JSON.stringify({total:all.length})}\n\n`);
        clearInterval(timer); res.end(); return;
      }
      const pr = all[i++];
      const hasImage = fs.existsSync(path.join(ROOT,'public',pr.image));
      res.write(`event: item\ndata: ${JSON.stringify({
        i, dw_sku:pr.dw_sku, mfr_sku:pr.mfr_sku, title:pr.title, pattern:pr.pattern,
        color:pr.color, color_family:pr.color_family, color_hex:pr.color_hex,
        image: hasImage ? pr.image : null, has_image: hasImage, tags:pr.tags,
        pricing:pr.pricing, created_at:pr.created_at, spec_ok:true
      })}\n\n`);
    }, speed);
    req.on('close', ()=>clearInterval(timer));
    return;
  }

  // index.html — inject bootstrap payload so first paint needs no second authed fetch
  if (p0==='/' || p0==='/index.html'){
    let html = fs.readFileSync(path.join(ROOT,'public','index.html'),'utf8');
    const boot = `<script>window.__BOOT__=${JSON.stringify({collection:collection(),products:products()})};</script>`;
    html = html.replace('</head>', boot+'\n</head>');
    return ok(res, html, 'text/html');
  }

  // other static files
  let file = path.join(ROOT, 'public', p0);
  if (!file.startsWith(path.join(ROOT,'public'))) { res.writeHead(403); return res.end('no'); }
  fs.readFile(file,(e,buf)=>{
    if (e){ res.writeHead(404); return res.end('not found'); }
    ok(res, buf, MIME[path.extname(file)]||'application/octet-stream');
  });
});

server.listen(PORT, ()=>{
  const port = server.address().port;
  fs.writeFileSync(path.join(ROOT,'.port'), String(port));
  console.log(`Majilite onboarding viewer  →  http://127.0.0.1:${port}  (admin/DW2024!)`);
});