[object Object]

← back to Sku Check Skill

Add pills live-check app: enter MFR or DW number, tick stock/price, check live

59bd09a50951f3d1370f2d0782656634298f332e · 2026-07-10 07:36:53 -0700 · Steve

- POST /api/check/live resolves either a DW number or MFR number (dash-insensitive)
  via dw_mfr_crossref, returns cached catalog price instantly + attempts the live
  yorkwall trade-portal pull (Brewster/York family) via yw-costcheck.js.
- Live pull is guarded: single-flight mutex, 20s min-gap, and a 30-min lockout
  backoff whenever the portal reports locked — the UI can never hammer the login.
- public/pills.html: pill-styled input + Stock/Price pill checkboxes + Check Live.
- De-fang stale hardcoded yorkwall password; creds now come from the secrets store.

Files touched

Diff

commit 59bd09a50951f3d1370f2d0782656634298f332e
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 10 07:36:53 2026 -0700

    Add pills live-check app: enter MFR or DW number, tick stock/price, check live
    
    - POST /api/check/live resolves either a DW number or MFR number (dash-insensitive)
      via dw_mfr_crossref, returns cached catalog price instantly + attempts the live
      yorkwall trade-portal pull (Brewster/York family) via yw-costcheck.js.
    - Live pull is guarded: single-flight mutex, 20s min-gap, and a 30-min lockout
      backoff whenever the portal reports locked — the UI can never hammer the login.
    - public/pills.html: pill-styled input + Stock/Price pill checkboxes + Check Live.
    - De-fang stale hardcoded yorkwall password; creds now come from the secrets store.
---
 public/pills.html | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 158 ++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 347 insertions(+), 4 deletions(-)

diff --git a/public/pills.html b/public/pills.html
new file mode 100644
index 0000000..44843e2
--- /dev/null
+++ b/public/pills.html
@@ -0,0 +1,193 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Live Check · Pills</title>
+<link rel="icon" href="/favicon.svg">
+<style>
+  :root{
+    --bg:#0f1115; --card:#171a21; --ink:#e8eaed; --muted:#9aa3af;
+    --lime:#84CC16; --lime-ink:#0f1115; --line:#242833;
+    --ok:#22c55e; --warn:#f59e0b; --bad:#ef4444; --info:#38bdf8;
+  }
+  *{box-sizing:border-box}
+  body{
+    margin:0; background:radial-gradient(1200px 600px at 50% -10%, #1b2130 0%, var(--bg) 60%);
+    color:var(--ink); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+    min-height:100vh; padding:32px 16px;
+  }
+  .wrap{max-width:620px; margin:0 auto}
+  h1{font-size:22px; font-weight:650; margin:0 0 4px; letter-spacing:-.01em}
+  .sub{color:var(--muted); margin:0 0 24px; font-size:13px}
+  .card{background:var(--card); border:1px solid var(--line); border-radius:22px; padding:22px}
+
+  /* ---- pill input ---- */
+  .field{display:flex; gap:10px; align-items:center}
+  input[type=text]{
+    flex:1; background:#0f131a; color:var(--ink); border:1px solid var(--line);
+    border-radius:999px; padding:14px 20px; font-size:16px; outline:none; transition:border .15s;
+  }
+  input[type=text]:focus{border-color:var(--lime)}
+  input::placeholder{color:#5b6472}
+
+  /* ---- pill checkboxes ---- */
+  .pills{display:flex; gap:10px; margin:16px 0 4px; flex-wrap:wrap}
+  .pill{
+    display:inline-flex; align-items:center; gap:8px; cursor:pointer; user-select:none;
+    background:#0f131a; border:1px solid var(--line); color:var(--muted);
+    padding:9px 16px; border-radius:999px; font-size:14px; font-weight:550; transition:all .15s;
+  }
+  .pill input{display:none}
+  .pill .dot{width:9px; height:9px; border-radius:50%; background:#3a4150; transition:background .15s}
+  .pill.on{background:rgba(132,204,22,.14); border-color:var(--lime); color:var(--lime)}
+  .pill.on .dot{background:var(--lime)}
+
+  /* ---- go button pill ---- */
+  button.go{
+    width:100%; margin-top:16px; background:var(--lime); color:var(--lime-ink);
+    border:none; border-radius:999px; padding:14px; font-size:16px; font-weight:700;
+    cursor:pointer; transition:filter .15s, opacity .15s;
+  }
+  button.go:hover{filter:brightness(1.06)}
+  button.go:disabled{opacity:.5; cursor:progress}
+
+  /* ---- results ---- */
+  .out{margin-top:20px; display:none}
+  .out.show{display:block}
+  .chip{
+    display:inline-flex; align-items:center; gap:6px; background:#0f131a; border:1px solid var(--line);
+    border-radius:999px; padding:6px 13px; font-size:13px; margin:0 6px 8px 0; color:var(--ink);
+  }
+  .chip b{color:var(--muted); font-weight:550}
+  .kv{display:flex; flex-wrap:wrap; margin-top:6px}
+  .sect{margin-top:18px; padding-top:16px; border-top:1px solid var(--line)}
+  .sect h3{margin:0 0 10px; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); font-weight:650}
+  .val{font-size:18px; font-weight:650}
+  .price-row{display:flex; gap:10px; flex-wrap:wrap}
+  .price-box{flex:1; min-width:120px; background:#0f131a; border:1px solid var(--line); border-radius:16px; padding:12px 14px}
+  .price-box .lbl{font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)}
+  .banner{border-radius:14px; padding:11px 15px; font-size:13.5px; margin-top:10px; border:1px solid}
+  .banner.info{background:rgba(56,189,248,.10); border-color:rgba(56,189,248,.4); color:#bae6fd}
+  .banner.warn{background:rgba(245,158,11,.10); border-color:rgba(245,158,11,.4); color:#fde68a}
+  .banner.bad{background:rgba(239,68,68,.10); border-color:rgba(239,68,68,.4); color:#fecaca}
+  .banner.ok{background:rgba(34,197,94,.10); border-color:rgba(34,197,94,.4); color:#bbf7d0}
+  .live-list{margin:8px 0 0; padding-left:18px}
+  .live-list li{margin:2px 0; font-size:13.5px}
+  .err{color:var(--bad); font-size:14px; margin-top:14px}
+  a{color:var(--lime)}
+  .spin{display:inline-block; width:14px; height:14px; border:2px solid rgba(15,17,21,.35); border-top-color:var(--lime-ink); border-radius:50%; animation:spin .7s linear infinite; vertical-align:-2px; margin-right:8px}
+  @keyframes spin{to{transform:rotate(360deg)}}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <h1>Live Check</h1>
+  <p class="sub">Enter a manufacturer number <em>or</em> a DW number, tick what to pull, check it live.</p>
+
+  <div class="card">
+    <div class="field">
+      <input id="q" type="text" placeholder="e.g. GGA-82668  or  437-RD336" autocomplete="off" autofocus>
+    </div>
+
+    <div class="pills">
+      <label class="pill on" id="pill-stock"><input type="checkbox" id="stock" checked><span class="dot"></span>Stock</label>
+      <label class="pill on" id="pill-price"><input type="checkbox" id="price" checked><span class="dot"></span>Price</label>
+    </div>
+
+    <button class="go" id="go">Check Live</button>
+
+    <div class="out" id="out"></div>
+  </div>
+</div>
+
+<script>
+  // pill toggle visuals
+  for (const key of ['stock','price']) {
+    const cb = document.getElementById(key);
+    const pill = document.getElementById('pill-'+key);
+    pill.addEventListener('click', () => setTimeout(() => pill.classList.toggle('on', cb.checked), 0));
+  }
+
+  const $ = id => document.getElementById(id);
+  const out = $('out');
+  const esc = s => String(s ?? '').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
+  const money = v => (v==null||v==='') ? '—' : (isNaN(+v) ? esc(v) : '$'+(+v).toFixed(2));
+
+  async function run() {
+    const query = $('q').value.trim();
+    if (!query) { $('q').focus(); return; }
+    const stock = $('stock').checked, price = $('price').checked;
+
+    $('go').disabled = true;
+    $('go').innerHTML = '<span class="spin"></span>Checking…';
+    out.className = 'out show';
+    out.innerHTML = '<p class="sub" style="margin:0">Resolving & pulling…</p>';
+
+    try {
+      const r = await fetch('/api/check/live', {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ query, stock, price })
+      });
+      const d = await r.json();
+      render(d);
+    } catch (e) {
+      out.innerHTML = '<p class="err">Request failed: '+esc(e.message)+'</p>';
+    } finally {
+      $('go').disabled = false;
+      $('go').textContent = 'Check Live';
+    }
+  }
+
+  function render(d) {
+    if (d.error) { out.innerHTML = '<p class="err">'+esc(d.error)+'</p>'; return; }
+    const rz = d.resolved || {};
+    let h = '<div class="kv">';
+    h += '<span class="chip"><b>'+ (rz.matchedBy==='mfr'?'MFR':'DW') +' →</b> '+esc(rz.dw_sku||'')+'</span>';
+    h += '<span class="chip"><b>MFR</b> '+esc(rz.mfr_sku||'')+'</span>';
+    h += '<span class="chip"><b>Vendor</b> '+esc(rz.vendor||'')+'</span>';
+    h += '</div>';
+
+    // cached prices
+    if (d.cached) {
+      const c = d.cached;
+      h += '<div class="sect"><h3>Cached (catalog)</h3><div class="price-row">';
+      h += '<div class="price-box"><div class="lbl">Retail</div><div class="val">'+money(c.retailPrice)+'</div></div>';
+      h += '<div class="price-box"><div class="lbl">Trade</div><div class="val">'+money(c.tradePrice)+'</div></div>';
+      h += '<div class="price-box"><div class="lbl">Cost</div><div class="val">'+money(c.costPrice)+'</div></div>';
+      h += '</div>';
+      if (c.productUrl) h += '<div style="margin-top:10px"><a href="'+esc(c.productUrl)+'" target="_blank" rel="noopener">Vendor product page ↗</a></div>';
+      h += '</div>';
+    }
+
+    // live
+    if (d.live) {
+      h += '<div class="sect"><h3>Live (trade portal)</h3>';
+      const L = d.live;
+      if (L.attempted === false) {
+        h += '<div class="banner warn">'+esc(L.reason)+'</div>';
+      } else if (L.error) {
+        h += '<div class="banner bad">'+esc(L.message||L.error)+'</div>';
+      } else if (L.loggedIn) {
+        h += '<div class="banner ok">'+esc(L.message)+'</div>';
+        if (L.priceStock && L.priceStock.length) {
+          h += '<ul class="live-list">'+ L.priceStock.map(x=>'<li>'+esc(x)+'</li>').join('') +'</ul>';
+        } else {
+          h += '<p class="sub" style="margin:8px 0 0">Logged in, but no price/stock text captured on the product page.</p>';
+        }
+      } else if (L.locked) {
+        h += '<div class="banner bad">'+esc(L.message)+'</div>';
+      } else {
+        h += '<div class="banner warn">'+esc(L.message||'No live data.')+'</div>';
+      }
+      h += '</div>';
+    }
+
+    out.innerHTML = h;
+  }
+
+  $('go').addEventListener('click', run);
+  $('q').addEventListener('keydown', e => { if (e.key==='Enter') run(); });
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index e79b016..03ef20f 100644
--- a/server.js
+++ b/server.js
@@ -1,9 +1,13 @@
 try { require('dotenv').config({ path: '/root/.env' }); } catch (_) {}
+const os = require('os');
+const path = require('path');
+// Canonical secrets store — holds YORKWALL_* (parsed safely, never bash-sourced).
+try { require('dotenv').config({ path: path.join(os.homedir(), 'Projects/secrets-manager/.env') }); } catch (_) {}
 const express = require('express');
 const helmet = require('helmet');
 const { Pool } = require('pg');
 const fetch = require('node-fetch');
-const path = require('path');
+const { execFile } = require('child_process');
 
 const app = express();
 // Security headers via helmet (added 2026-05-04 overnight YOLO loop)
@@ -28,10 +32,14 @@ const SLACK_WEBHOOK = 'https://hooks.slack.com/services/T03U65C1G7J/B09RCFHS7PW/
 const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';
 const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
 
-// Brewster / York trade portal credentials (same parent company, shared login)
+// Brewster / York trade portal — the ONLY source of live trade cost + stock for
+// the whole Brewster/York/Jeffrey-Stevens family. Credentials come from the
+// secrets store (YORKWALL_USER / YORKWALL_PASS); never hardcode them here (the
+// portal locks the account for 30 min on repeated bad logins). The live pull is
+// delegated to the yw-costcheck.js scraper, which owns the abort-if-locked guard.
 const YORKWALL_LOGIN_URL = 'https://www.yorkwall.com/Login/';
-const YORKWALL_USERNAME = '6003784';
-const YORKWALL_PASSWORD = '*Brewsteraccess911*';
+const YORKWALL_USERNAME = process.env.YORKWALL_USER || '6003784';
+const YW_SCRIPT = path.join(os.homedir(), 'Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/yw-costcheck.js');
 
 // Vendor catalog table mapping (vendor_code -> PostgreSQL table info)
 const VENDOR_CATALOG = {
@@ -1148,6 +1156,148 @@ app.post('/api/action/vendor-url', async (req, res) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// ===== LIVE CHECK (pills app) =====
+// Resolve a free-text query that may be EITHER a DW number (dw_sku / dw_sku_dash)
+// OR a manufacturer number (mfr_sku), dash-insensitive, against the crossref.
+async function resolveQuery(q) {
+  const raw = (q || '').trim();
+  if (!raw) return null;
+  const clean = raw.replace(/-/g, '');
+  // 1) DW-number match first (that's the DW-native identifier).
+  let r = await pool.query(
+    `SELECT dw_sku, dw_sku_dash, mfr_sku, vendor_code, vendor_name
+     FROM dw_mfr_crossref
+     WHERE dw_sku = $1 OR dw_sku_dash = $1 OR REPLACE(dw_sku,'-','') = $2
+     LIMIT 1`, [raw, clean]);
+  if (r.rows[0]) return { matchedBy: 'dw', ...r.rows[0] };
+  // 2) Fall back to a manufacturer-number match.
+  r = await pool.query(
+    `SELECT dw_sku, dw_sku_dash, mfr_sku, vendor_code, vendor_name
+     FROM dw_mfr_crossref
+     WHERE mfr_sku = $1 OR REPLACE(mfr_sku,'-','') = $2
+     LIMIT 1`, [raw, clean]);
+  if (r.rows[0]) return { matchedBy: 'mfr', ...r.rows[0] };
+  return null;
+}
+
+// Live-portal guard state. The yorkwall trade portal LOCKS the account for 30 min
+// on repeated logins and RESETS the timer if you retry while locked — so the app
+// must be physically unable to hammer it. One in-flight live check at a time, a
+// minimum gap between attempts, and a 30-min backoff whenever a run reports locked.
+let liveInFlight = false;
+let lastLiveAttemptMs = 0;
+let lockoutUntilMs = 0;
+const LIVE_MIN_GAP_MS = 20 * 1000;      // ignore rapid double-clicks
+const LIVE_LOCKOUT_MS = 30 * 60 * 1000; // honor the portal's 30-min lockout
+
+// Run the yorkwall scraper for one MFR sku (Brewster/York family only).
+function runYorkwallLive(mfrSku) {
+  return new Promise((resolve) => {
+    execFile('node', [YW_SCRIPT, mfrSku], {
+      cwd: path.dirname(YW_SCRIPT),
+      timeout: 90000,
+      maxBuffer: 4 * 1024 * 1024,
+    }, (err, stdout) => {
+      let parsed = null;
+      try { parsed = JSON.parse(stdout); } catch (_) { /* non-JSON */ }
+      if (!parsed) return resolve({ ok: false, error: err ? err.message : 'no JSON from scraper', raw: (stdout || '').slice(0, 500) });
+      resolve({ ok: true, data: parsed });
+    });
+  });
+}
+
+// POST /api/check/live  { query, stock, price }
+// Always returns the cached catalog price/stock instantly; attempts the LIVE
+// yorkwall pull only when asked, only for the Brewster/York family, and only when
+// the guard permits (not in-flight, not cooling down, past the min gap).
+app.post('/api/check/live', async (req, res) => {
+  const { query } = req.body;
+  const wantStock = req.body.stock === true || req.body.stock === 'true';
+  const wantPrice = req.body.price === true || req.body.price === 'true';
+  if (!query) return res.status(400).json({ error: 'query (MFR or DW number) required' });
+
+  try {
+    const match = await resolveQuery(query);
+    if (!match) return res.json({ error: `No crossref match for "${query}"`, query });
+
+    const vc = findVendorCatalog(match.vendor_code, match.vendor_name);
+    const out = {
+      query,
+      resolved: {
+        matchedBy: match.matchedBy,
+        dw_sku: match.dw_sku_dash || match.dw_sku,
+        mfr_sku: match.mfr_sku,
+        vendor: vc?.name || match.vendor_name || match.vendor_code,
+        vendor_code: match.vendor_code,
+      },
+      want: { stock: wantStock, price: wantPrice },
+    };
+
+    // Cached tier — instant, always present when a catalog row exists.
+    const specsResult = await fetchSpecsFromCatalog(match.mfr_sku, match.vendor_code, match.vendor_name);
+    const s = specsResult.specs || {};
+    out.cached = {
+      source: vc?.table || null,
+      retailPrice: s.retailPrice ?? null,
+      tradePrice: s.tradePrice ?? null,
+      costPrice: s.costPrice ?? null,
+      productUrl: s.productUrl || null,
+      message: specsResult.message,
+    };
+
+    // Live tier — only if the user asked for stock/price.
+    if (wantStock || wantPrice) {
+      const hasPortal = !!(vc && vc.tradePortal); // Brewster/York family only
+      if (!hasPortal) {
+        out.live = { attempted: false, reason: `No live trade portal wired for ${out.resolved.vendor} — showing cached only.` };
+      } else if (liveInFlight) {
+        out.live = { attempted: false, reason: 'A live check is already running — one at a time to protect the portal login. Try again in a moment.' };
+      } else if (Date.now() < lockoutUntilMs) {
+        const mins = Math.ceil((lockoutUntilMs - Date.now()) / 60000);
+        out.live = { attempted: false, reason: `Portal is in a lockout cooldown — retry in ~${mins} min. (Repeated logins reset the lock, so live checks are paused.)` };
+      } else if (Date.now() - lastLiveAttemptMs < LIVE_MIN_GAP_MS) {
+        const secs = Math.ceil((LIVE_MIN_GAP_MS - (Date.now() - lastLiveAttemptMs)) / 1000);
+        out.live = { attempted: false, reason: `Too soon after the last live check — wait ${secs}s.` };
+      } else {
+        liveInFlight = true;
+        lastLiveAttemptMs = Date.now();
+        try {
+          const r = await runYorkwallLive(match.mfr_sku);
+          if (r.ok && r.data) {
+            const d = r.data;
+            // If the scraper reports a lockout, arm the 30-min backoff.
+            if (d.locked || d.abort === 'STILL_LOCKED') lockoutUntilMs = Date.now() + LIVE_LOCKOUT_MS;
+            out.live = {
+              attempted: true,
+              loggedIn: !!d.loggedIn,
+              locked: !!d.locked,
+              authError: !!d.authError,
+              abort: d.abort || null,
+              priceStock: Array.isArray(d.priceStock) ? d.priceStock : [],
+              pageTitle: d.pageTitle || null,
+              message: d.loggedIn
+                ? 'Live portal pull complete.'
+                : (d.locked ? 'Portal is locked — backing off 30 min.' : (d.authError ? 'Portal rejected the login.' : 'Could not reach the product page.')),
+            };
+          } else {
+            out.live = { attempted: true, error: r.error || 'scraper failed', message: 'Live scraper did not return data.' };
+          }
+        } finally {
+          liveInFlight = false;
+        }
+      }
+    }
+
+    res.json(out);
+  } catch (e) {
+    console.error('Live check error:', e);
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// Friendly route for the pills app (public/pills.html is also served statically).
+app.get('/pills', (req, res) => res.sendFile(path.join(__dirname, 'public', 'pills.html')));
+
 // Add unique constraint for upsert support
 (async () => {
   try {

← 799c755 auto-save: 2026-07-10T00:12:54 (1 files) — package-lock.json  ·  back to Sku Check Skill  ·  pills app: add last-portal-pull cache (reads yw-results, wri 803ecd6 →