← 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
A public/pills.htmlM server.js
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 => ({'<':'<','>':'>','&':'&'}[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 →