[object Object]

← back to New Import Viewer

Wire live Shopify unpublish behind double gate (master switch + per-action allowlist); fix admin-link store handle to designer-laboratory-sandbox

b2cb29ba8bfbbe4be7019362a84cf74abfe4bf17 · 2026-06-09 18:09:33 -0700 · SteveStudio2

Files touched

Diff

commit b2cb29ba8bfbbe4be7019362a84cf74abfe4bf17
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 9 18:09:33 2026 -0700

    Wire live Shopify unpublish behind double gate (master switch + per-action allowlist); fix admin-link store handle to designer-laboratory-sandbox
---
 data/launch-queue.jsonl |  5 +++
 public/index.html       | 44 ++++++++++++++++-------
 server.js               | 93 ++++++++++++++++++++++++++++++++++++++++++++-----
 3 files changed, 121 insertions(+), 21 deletions(-)

diff --git a/data/launch-queue.jsonl b/data/launch-queue.jsonl
index d49d82e..2a6d9fc 100644
--- a/data/launch-queue.jsonl
+++ b/data/launch-queue.jsonl
@@ -1 +1,6 @@
 {"batchId":"unpublish-1-67187-mq7d4mkl","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:01:04.965Z"}
+{"batchId":"unpublish-1-676255-mq7d9o6w","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:05:00.344Z"}
+{"batchId":"unpublish-1-67187-mq7daag0","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:05:29.184Z"}
+{"batchId":"unpublish-1-67187-mq7ddvev","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:08:16.328Z"}
+{"batchId":"unpublish-1-67188-mq7dengg","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:08:52.672Z"}
+{"batchId":"unpublish-1-67187-mq7df73l","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:09:18.129Z"}
diff --git a/public/index.html b/public/index.html
index c6123e9..1651b10 100644
--- a/public/index.html
+++ b/public/index.html
@@ -66,17 +66,35 @@ input[type=range]{accent-color:var(--acc)}
 #selbar .n{font-weight:700;color:var(--acc)}
 #bulkgo{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
 #bulkgo:disabled{opacity:.4;cursor:default}
-/* ── List view ── */
-.grid.list{grid-template-columns:1fr;gap:4px;padding:12px 16px}
-.grid.list .card{flex-direction:row;align-items:center;border-radius:8px;flex-wrap:wrap}
-.grid.list .imgwrap{width:52px;min-width:52px;height:52px;aspect-ratio:auto;border-radius:6px;overflow:hidden;margin:6px 0 6px 6px}
-.grid.list .imgwrap.noimg::after{font-size:9px}
-.grid.list .pick{position:static;background:none;padding:0 6px 0 8px;flex:0 0 auto}
-.grid.list .b{flex:1;flex-direction:row;align-items:center;gap:14px;padding:6px 14px;flex-wrap:nowrap;overflow:hidden}
-.grid.list .vend{order:1;flex:0 0 150px;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .ttl{order:2;flex:1 1 200px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .chips{order:3;flex:0 0 auto}
-.grid.list .details{flex:1 1 100%;order:5}
+/* ── List view ── aligned CSS-grid rows. Each .card is itself a grid with a
+   shared column template; .b uses display:contents so vendor/title/chips/
+   list-cells become columns of the SAME card grid → every row lines up.
+   Columns: checkbox · 42px thumb · vendor · pattern+color · status chip ·
+   SKU · 🕓 crawl date+time (full, left-aligned). The .details drawer is a
+   full-width row spanning all columns, dropping below when the card is open.
+   Compact ~52px rows. List-only SKU + crawl cells (.lsku/.lcrawl) are hidden
+   in grid view and shown only here. */
+.grid.list{grid-template-columns:1fr;gap:4px;padding:12px 16px 60px}
+.grid.list .card{
+  display:grid;align-items:center;border-radius:8px;
+  grid-template-columns:26px 42px 150px minmax(150px,1.7fr) 128px minmax(110px,0.9fr) 168px;
+  column-gap:14px;min-height:52px;padding:0 12px 0 8px;
+}
+.grid.list .card>.b{display:contents}
+.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:6px;overflow:hidden;margin:5px 0}
+.grid.list .imgwrap img{object-position:center}
+.grid.list .imgwrap.noimg::after{font-size:8px}
+.grid.list .pick{position:static;background:none;padding:0;justify-content:center}
+.grid.list .vend{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .chips{flex-wrap:nowrap;gap:6px;overflow:hidden}
+.grid.list .chips .chip.toggle{font-size:9.5px;padding:2px 7px}
+.grid.list .lsku{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .lcrawl{font-size:11px;color:var(--mut);white-space:nowrap;justify-self:start;text-align:left}
+.grid.list .details{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
+/* list-only cells: hidden in the default card grid, shown only in list view */
+.lsku,.lcrawl{display:none}
+.grid.list .lsku,.grid.list .lcrawl{display:block}
 </style></head>
 <body>
 <header>
@@ -142,7 +160,7 @@ $('#sort').value=localStorage.getItem('nip.sort')||'newest';
 const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
 document.documentElement.style.setProperty('--cols',dens);
 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
-const SHOP='https://admin.shopify.com/store/designer-wallcoverings/products/';
+const SHOP='https://admin.shopify.com/store/designer-laboratory-sandbox/products/';
 // Which staged actions make sense for a given state.
 function actionsFor(st){
   if(st==='NEW') return [['launch:draft','Stage → Draft',''],['launch:active','Stage → Active','pub']];
@@ -172,6 +190,8 @@ function card(p){
       <span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
       <span class="chip toggle" onclick="toggleCard(this)">⋯ details &amp; actions</span>
     </div>
+    <span class="lsku" title="Vendor SKU">${esc(p.sku||p.dwSku||'')}</span>
+    <span class="lcrawl" title="${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
     <div class="details">
       ${kv('Vendor SKU',p.sku)}${kv('DW SKU',p.dwSku)}${kv('Collection',p.collection)}
       ${kv('Type',p.type)}${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
diff --git a/server.js b/server.js
index 07cd2b8..6e542cb 100644
--- a/server.js
+++ b/server.js
@@ -14,10 +14,60 @@ const PORT = process.env.PORT || 9790;
 const PSQL = process.env.PSQL || '/opt/homebrew/opt/postgresql@14/bin/psql';
 const PGUSER = process.env.PGUSER || 'stevestudio2';
 const PGDB = process.env.PGDATABASE || 'dw_unified';
-const LIVE = process.env.SHOPIFY_LIVE_WRITES === '1';   // gate: real store writes
 const US = '\x1f';        // unit separator — safe field delim for text columns
 const RS = '\x1e';        // record separator
 
+// ── Secrets / live-write gate ──────────────────────────────────────────────
+// Token is read from this project's .env first, else Steve's secrets master.
+// Never logged, never written into the repo (.env is gitignored).
+function loadEnv() {
+  const files = [path.join(__dirname, '.env'), '/Users/stevestudio2/Projects/secrets-manager/.env'];
+  const env = {};
+  for (const f of files) {
+    try {
+      for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
+        const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+        if (m && env[m[1]] === undefined) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+      }
+    } catch { /* file may not exist */ }
+  }
+  return env;
+}
+const ENV = loadEnv();
+const SHOP_STORE = process.env.SHOPIFY_STORE || ENV.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const SHOP_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || ENV.SHOPIFY_ADMIN_TOKEN || '';
+const SHOP_API = '2024-10';
+// ── HARD master gate ──────────────────────────────────────────────────────
+// Live Shopify writes fire ONLY when SHOPIFY_LIVE_WRITES=1 is explicitly set.
+// Default is OFF → every action stages to the launch queue and NOTHING reaches
+// the store. This gate is intentionally a single, obvious boolean so it can't be
+// flipped on by a stray per-action allowlist. Keep it OFF unless Steve flips it.
+const LIVE = (process.env.SHOPIFY_LIVE_WRITES || ENV.SHOPIFY_LIVE_WRITES) === '1';
+// Secondary per-action allowlist — only consulted WHEN the master gate is ON.
+// With LIVE off this set is irrelevant; an action only writes if BOTH the master
+// gate is on AND the action is allow-listed.
+const LIVE_ACTIONS = new Set((process.env.LIVE_ACTIONS || ENV.LIVE_ACTIONS || '')
+  .split(',').map(s => s.trim()).filter(Boolean));
+function isLiveAction(action) { return LIVE && !!SHOP_TOKEN && LIVE_ACTIONS.has(action); }
+
+// Live Shopify Admin write for one product. Returns {ok,status,error}.
+// unpublish → set product status=draft (pulls it off the storefront, reversible).
+async function shopifyWrite(action, productId) {
+  if (!SHOP_TOKEN) return { ok: false, error: 'no token' };
+  const map = { unpublish: 'draft', publish: 'active', archive: 'archived' };
+  const status = map[action];
+  if (!status) return { ok: false, error: `live action '${action}' not supported` };
+  const url = `https://${SHOP_STORE}/admin/api/${SHOP_API}/products/${productId}.json`;
+  const r = await fetch(url, {
+    method: 'PUT',
+    headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'content-type': 'application/json' },
+    body: JSON.stringify({ product: { id: Number(productId), status } }),
+  });
+  if (!r.ok) return { ok: false, error: `HTTP ${r.status} ${(await r.text()).slice(0, 160)}` };
+  const j = await r.json().catch(() => ({}));
+  return { ok: true, status: j.product?.status || status };
+}
+
 // NEW-to-import predicate — the single source of truth for "new vs Shopify".
 const NEW_PRED = "(vc.sync_status='new' OR ((vc.on_shopify IS NOT TRUE) AND vc.shopify_product_id IS NULL))";
 // Resolve one shopify_products row per vendor_catalog row. shopify_id is a
@@ -157,11 +207,12 @@ const server = http.createServer((req, res) => {
       return send(res, 200, JSON.stringify({ count: cnt, offset, limit, items }));
     }
     // Generalized action endpoint — stages launch / publish / unpublish / archive.
+    // Actions in LIVE_ACTIONS additionally fire a real Shopify write.
     // /api/launch kept as a back-compat alias (action='launch').
     if ((u.pathname === '/api/action' || u.pathname === '/api/launch') && req.method === 'POST') {
       let body = '';
       req.on('data', c => (body += c));
-      req.on('end', () => {
+      req.on('end', async () => {
         try {
           const d = JSON.parse(body || '{}');
           const ids = Array.isArray(d.ids) ? d.ids.filter(n => Number.isInteger(n)).slice(0, 5000) : [];
@@ -172,12 +223,36 @@ const server = http.createServer((req, res) => {
           const mode = d.mode === 'active' ? 'active' : 'draft';   // only meaningful for launch
           if (!ids.length) return send(res, 400, JSON.stringify({ error: 'no products selected' }));
           const out = stageAction({ ids, action, mode });
-          // LIVE writes are scaffolded but gated. When SHOPIFY_LIVE_WRITES=1 this is
-          // where the Shopify Admin API call would fire per action; left disabled
-          // until Steve OKs each action type.
-          out.note = LIVE
-            ? 'staged — live writes ENABLED but per-action push not yet wired (awaiting sign-off)'
-            : 'staged only — live Shopify writes are OFF (SHOPIFY_LIVE_WRITES!=1)';
+
+          if (isLiveAction(action)) {
+            // LIVE PATH — only reachable when SHOPIFY_LIVE_WRITES=1 AND the action
+            // is in LIVE_ACTIONS AND a token is present. Resolve each row's
+            // shopify_product_id and write to the store.
+            const rows = q(`SELECT vc.id, vc.shopify_product_id
+                            FROM vendor_catalog vc WHERE vc.id IN (${ids.join(',')})`);
+            const results = []; let ok = 0, skipped = 0, failed = 0;
+            for (const r of rows) {
+              const vcId = +r[0], pid = r[1];
+              if (!pid) { skipped++; results.push({ id: vcId, skipped: 'not on Shopify' }); continue; }
+              const w = await shopifyWrite(action, pid);
+              if (w.ok) {
+                ok++;
+                // reflect new state locally so the chip updates immediately
+                try { q(`UPDATE shopify_products SET status='${esc(w.status)}'
+                         WHERE shopify_id='gid://shopify/Product/${esc(String(pid))}'`); } catch {}
+                results.push({ id: vcId, ok: true, status: w.status });
+              } else { failed++; results.push({ id: vcId, error: w.error }); }
+            }
+            out.live = true;
+            out.applied = { ok, skipped, failed };
+            out.results = results;
+            out.store = SHOP_STORE;
+            out.note = `LIVE ${action} on ${SHOP_STORE}: ${ok} ok, ${skipped} skipped (not on Shopify), ${failed} failed`;
+          } else {
+            out.note = !LIVE
+              ? 'staged only — live Shopify writes are OFF (SHOPIFY_LIVE_WRITES!=1)'
+              : `staged only — '${action}' is not in the live-write allowlist (${[...LIVE_ACTIONS].join(',')||'none'})`;
+          }
           return send(res, 200, JSON.stringify(out));
         } catch (e) { return send(res, 500, JSON.stringify({ error: e.message })); }
       });
@@ -188,4 +263,4 @@ const server = http.createServer((req, res) => {
     return send(res, 500, JSON.stringify({ error: e.message }));
   }
 });
-server.listen(PORT, () => console.log(`[new-import-viewer] http://127.0.0.1:${PORT}  (Shopify-state catalog · live-writes=${LIVE ? 'ON' : 'OFF'})`));
+server.listen(PORT, () => console.log(`[new-import-viewer] http://127.0.0.1:${PORT}  · store=${SHOP_STORE} · live-actions=[${[...LIVE_ACTIONS].join(',') || 'none'}]${SHOP_TOKEN ? '' : ' (NO TOKEN)'}`));

← d24c5ab Show full Shopify state (new/published/unpublished/archived)  ·  back to New Import Viewer  ·  Force live-write gate OFF by default (SHOPIFY_LIVE_WRITES ma 16d4ca8 →