[object Object]

← back to Marketing Command Center

Vendor IG roster: add last-10-posts grid per vendor via Instagram Business Discovery (server-side, cached, graceful degrade on expired token)

96afa0ac84b74b1502dda193f855e291892afd5b · 2026-06-16 05:39:41 -0700 · Steve Abrams

Files touched

Diff

commit 96afa0ac84b74b1502dda193f855e291892afd5b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 16 05:39:41 2026 -0700

    Vendor IG roster: add last-10-posts grid per vendor via Instagram Business Discovery (server-side, cached, graceful degrade on expired token)
---
 modules/vendors/index.js            | 102 +++++++++++++++++++++++++++++++-----
 public/panels/vendors.js            |  88 +++++++++++++++++++++++++++----
 scripts/probe-business-discovery.js |  37 +++++++++++++
 3 files changed, 203 insertions(+), 24 deletions(-)

diff --git a/modules/vendors/index.js b/modules/vendors/index.js
index b3f1c2a..d097399 100644
--- a/modules/vendors/index.js
+++ b/modules/vendors/index.js
@@ -1,12 +1,20 @@
 // Vendor IG Reporting — directory of DW's + all vendor Instagram accounts, for
 // competitive/social reporting. Reads data/vendor-instagram.json (curated; refresh
-// via research). Read-only. Pairs with the advertising-signals tooling for deeper
-// per-vendor ad/social intel later.
+// via research). Now also fetches each account's LAST 10 POSTS via Instagram
+// Business Discovery (DW's own IG business account can read any public IG business
+// account's recent media). Posts are cached to data/vendor-posts-cache.json; the
+// grid renders from cache, and POST /posts/refresh re-crawls. Read-only re: writes.
 const fs = require('fs');
 const path = require('path');
 
+const GRAPH = 'https://graph.facebook.com/v23.0';
 const DATA = path.join(__dirname, '..', '..', 'data', 'vendor-instagram.json');
+const CACHE = path.join(__dirname, '..', '..', 'data', 'vendor-posts-cache.json');
+
 function load() { try { return JSON.parse(fs.readFileSync(DATA, 'utf8')); } catch { return []; } }
+function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch { return {}; } }
+function saveCache(c) { fs.writeFileSync(CACHE, JSON.stringify(c, null, 2)); }
+function env(k) { try { return (fs.readFileSync(path.join(__dirname, '..', '..', '.env'), 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1]; } catch { return null; } }
 function followersToNum(f) {
   if (!f) return 0;
   const m = String(f).trim().match(/^([\d.]+)\s*([kKmM]?)/);
@@ -14,6 +22,39 @@ function followersToNum(f) {
   const n = parseFloat(m[1]); const u = m[2].toLowerCase();
   return Math.round(n * (u === 'm' ? 1e6 : u === 'k' ? 1e3 : 1));
 }
+const handleOf = r => String(r.handle || '').replace(/^@/, '').trim();
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// Find a DW-owned IG business account id to use as the business_discovery node.
+let _igNode = null;
+async function discoveringIg(token) {
+  if (_igNode) return _igNode;
+  const r = await fetch(`${GRAPH}/me/accounts?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(token)}`);
+  const j = await r.json();
+  if (j.error) throw new Error(j.error.message);
+  const p = (j.data || []).find(p => p.instagram_business_account);
+  if (!p) throw new Error('no IG business account linked to any Page');
+  _igNode = { id: p.instagram_business_account.id, username: p.instagram_business_account.username };
+  return _igNode;
+}
+
+async function fetchPosts(igId, token, username) {
+  const fields = `business_discovery.username(${username}){username,followers_count,media_count,media.limit(10){id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count}}`;
+  const r = await fetch(`${GRAPH}/${igId}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(token)}`);
+  const j = await r.json();
+  if (j.error) throw new Error(j.error.message);
+  const bd = j.business_discovery || {};
+  return {
+    followers_count: bd.followers_count ?? null,
+    media_count: bd.media_count ?? null,
+    posts: (bd.media?.data || []).map(m => ({
+      id: m.id, caption: (m.caption || '').slice(0, 280), media_type: m.media_type,
+      image: m.media_type === 'VIDEO' ? (m.thumbnail_url || m.media_url) : m.media_url,
+      permalink: m.permalink, timestamp: m.timestamp,
+      likes: m.like_count ?? null, comments: m.comments_count ?? null,
+    })),
+  };
+}
 
 module.exports = {
   id: 'vendors',
@@ -21,25 +62,60 @@ module.exports = {
   icon: '📷',
   mount(router) {
     router.get('/accounts', (_req, res) => {
-      const rows = load().map(r => ({
-        ...r,
-        followersNum: followersToNum(r.followersVerified || r.followers),
-        followersDisplay: r.followersVerified || r.followers,
-        verifiedLive: r.live === true && !!r.followersVerified,
-        hasIG: r.handle && r.handle !== 'none found',
-      }));
+      const cache = loadCache();
+      const rows = load().map(r => {
+        const h = handleOf(r);
+        const c = cache[h] || null;
+        return {
+          ...r,
+          followersNum: followersToNum(r.followersVerified || r.followers),
+          followersDisplay: r.followersVerified || r.followers,
+          verifiedLive: r.live === true && !!r.followersVerified,
+          hasIG: r.handle && r.handle !== 'none found',
+          posts: c?.posts || [],
+          postsFetchedAt: c?.fetchedAt || null,
+          postsError: c?.error || null,
+        };
+      });
       const withIG = rows.filter(r => r.hasIG);
       const reach = withIG.reduce((s, r) => s + r.followersNum, 0);
+      const withPosts = rows.filter(r => r.posts && r.posts.length).length;
       res.json({
         accounts: rows,
         stats: {
-          total: rows.length,
-          withIG: withIG.length,
-          missing: rows.length - withIG.length,
-          totalReach: reach,
+          total: rows.length, withIG: withIG.length, missing: rows.length - withIG.length,
+          totalReach: reach, withPosts,
+          postsFetchedAt: Object.values(cache).map(c => c.fetchedAt).filter(Boolean).sort().pop() || null,
           dw: rows.find(r => r.vendorCode === 'dw') || null,
         },
       });
     });
+
+    // Re-crawl last-10 posts for all (or ?handle=) vendors via business_discovery.
+    router.post('/posts/refresh', async (req, res) => {
+      const token = env('META_ACCESS_TOKEN');
+      if (!token) return res.json({ ok: false, error: 'META_ACCESS_TOKEN not set in .env' });
+      let ig;
+      try { ig = await discoveringIg(token); }
+      catch (e) { return res.json({ ok: false, error: 'token/IG check failed: ' + e.message + (/expired|session/i.test(e.message) ? ' — paste a fresh long-lived META_ACCESS_TOKEN' : '') }); }
+      const only = (req.query.handle || '').replace(/^@/, '').trim();
+      const cache = loadCache();
+      const targets = load().filter(r => { const h = handleOf(r); return h && r.handle !== 'none found' && (!only || h === only); });
+      let ok = 0, failed = 0;
+      for (const r of targets) {
+        const h = handleOf(r);
+        try {
+          const data = await fetchPosts(ig.id, token, h);
+          cache[h] = { ...data, fetchedAt: new Date().toISOString(), error: null };
+          ok++;
+        } catch (e) {
+          cache[h] = { ...(cache[h] || {}), error: e.message, fetchedAt: new Date().toISOString() };
+          failed++;
+        }
+        saveCache(cache);
+        await sleep(600); // be gentle on Graph rate limits
+      }
+      res.json({ ok: true, discoveringIg: '@' + ig.username, refreshed: ok, failed, total: targets.length });
+    });
   },
 };
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index d52b858..9900769 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -3,13 +3,37 @@ window.MCC_PANELS['vendors'] = {
   async init(root) {
     const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
     const fmt = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'K' : (n || '—');
+    const ago = iso => { if (!iso) return ''; const d = (Date.now() - new Date(iso)) / 36e5; return d < 1 ? Math.round(d * 60) + 'm ago' : d < 24 ? Math.round(d) + 'h ago' : Math.round(d / 24) + 'd ago'; };
     let data;
-    try { data = await (await fetch('/api/vendors/accounts')).json(); } catch { root.querySelector('#vend-rows').innerHTML = '<div class="muted">Failed to load.</div>'; return; }
-    const s = data.stats || {};
-    root.querySelector('#vs-total').textContent = s.total || 0;
-    root.querySelector('#vs-ig').textContent = s.withIG || 0;
-    root.querySelector('#vs-reach').textContent = fmt(s.totalReach || 0);
-    root.querySelector('#vs-missing').textContent = s.missing || 0;
+    const loadData = async () => { data = await (await fetch('/api/vendors/accounts')).json(); };
+    try { await loadData(); } catch { root.querySelector('#vend-rows').innerHTML = '<div class="muted">Failed to load.</div>'; return; }
+
+    const setStats = () => {
+      const s = data.stats || {};
+      root.querySelector('#vs-total').textContent = s.total || 0;
+      root.querySelector('#vs-ig').textContent = s.withIG || 0;
+      root.querySelector('#vs-reach').textContent = fmt(s.totalReach || 0);
+      root.querySelector('#vs-missing').textContent = s.missing || 0;
+    };
+    setStats();
+
+    const postGrid = (r) => {
+      if (!r.hasIG) return '';
+      if (r.posts && r.posts.length) {
+        const cells = r.posts.map(p => {
+          const cap = esc((p.caption || '').replace(/\s+/g, ' ').slice(0, 140));
+          const meta = `♥ ${fmt(p.likes)}  ·  💬 ${fmt(p.comments)}`;
+          const vid = p.media_type === 'VIDEO' ? '<span class="ig-vid">▶</span>' : '';
+          return `<a class="ig-cell" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer" title="${esc(p.timestamp ? p.timestamp.slice(0,10) : '')}  ${meta}\n${cap}">
+            <img loading="lazy" src="${esc(p.image || '')}" alt="" onerror="this.parentNode.classList.add('ig-broken')">
+            ${vid}<span class="ig-meta">${meta}</span></a>`;
+        }).join('');
+        return `<div class="ig-grid">${cells}</div>`;
+      }
+      const why = r.postsError ? `posts unavailable — ${esc(r.postsError).slice(0, 90)}` : 'no posts cached yet — click “Refresh posts”';
+      return `<div class="muted" style="font-size:11px;padding:4px 2px 8px">${why}</div>`;
+    };
+
     const render = (sort) => {
       let rows = [...data.accounts];
       if (sort === 'brand') rows.sort((a, b) => a.brand.localeCompare(b.brand));
@@ -20,16 +44,58 @@ window.MCC_PANELS['vendors'] = {
         const handle = r.hasIG
           ? `<a class="lnk" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.handle)} ↗</a>`
           : `<span class="muted">no IG</span>`;
-        return `<div class="row" style="justify-content:space-between;border-bottom:1px solid var(--line);padding:9px 2px;align-items:center${dw ? ';background:#faf6ee' : ''}">
-          <div style="min-width:200px"><b>${esc(r.brand)}</b>${dw ? ' <span class="pill">OURS</span>' : ''}${r.note && !dw ? ` <span class="muted" style="font-size:11px">${esc(r.note)}</span>` : ''}</div>
-          <div style="flex:1">${handle}</div>
-          <div style="min-width:70px;text-align:right;font-variant-numeric:tabular-nums">${r.hasIG ? fmt(r.followersNum) : ''}</div>
+        return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px${dw ? ';background:#faf6ee' : ''}">
+          <div class="row" style="justify-content:space-between;align-items:center">
+            <div style="min-width:200px"><b>${esc(r.brand)}</b>${dw ? ' <span class="pill">OURS</span>' : ''}${r.note && !dw ? ` <span class="muted" style="font-size:11px">${esc(r.note)}</span>` : ''}</div>
+            <div style="flex:1">${handle}</div>
+            <div style="min-width:70px;text-align:right;font-variant-numeric:tabular-nums">${r.hasIG ? fmt(r.followersNum) : ''}</div>
+          </div>
+          ${postGrid(r)}
         </div>`;
       }).join('');
     };
     render('followers');
     root.querySelector('#vs-sort').onchange = e => render(e.target.value);
+
+    // Refresh-posts control + last-fetched stamp, injected into the stats bar.
+    const bar = root.querySelector('#vend-stats');
+    if (bar && !bar.querySelector('#vp-refresh')) {
+      const wrap = document.createElement('div');
+      wrap.style.cssText = 'display:flex;flex-direction:column;align-items:flex-end;gap:2px';
+      wrap.innerHTML = `<button id="vp-refresh" class="btn">↻ Refresh posts</button>
+        <small class="muted" id="vp-when">${data.stats.postsFetchedAt ? 'posts updated ' + ago(data.stats.postsFetchedAt) : 'posts not fetched yet'} · ${data.stats.withPosts || 0}/${data.stats.withIG || 0} with posts</small>`;
+      bar.appendChild(wrap);
+      wrap.querySelector('#vp-refresh').onclick = async (e) => {
+        const btn = e.target; btn.disabled = true; btn.textContent = '↻ Fetching last 10 posts…';
+        try {
+          const res = await (await fetch('/api/vendors/posts/refresh', { method: 'POST' })).json();
+          if (!res.ok) { btn.textContent = '⚠ ' + (res.error || 'refresh failed'); btn.disabled = false; return; }
+          await loadData(); setStats();
+          render(root.querySelector('#vs-sort').value);
+          btn.textContent = `✓ ${res.refreshed} ok, ${res.failed} failed (via ${res.discoveringIg})`;
+          root.querySelector('#vp-when').textContent = 'posts updated just now · ' + (data.stats.withPosts || 0) + '/' + (data.stats.withIG || 0) + ' with posts';
+          setTimeout(() => { btn.textContent = '↻ Refresh posts'; btn.disabled = false; }, 4000);
+        } catch (err) { btn.textContent = '⚠ ' + err.message; btn.disabled = false; }
+      };
+    }
+
+    // Grid styling (idempotent)
+    if (!document.getElementById('vend-ig-css')) {
+      const st = document.createElement('style'); st.id = 'vend-ig-css';
+      st.textContent = `
+      .ig-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;margin-top:9px}
+      .ig-cell{position:relative;aspect-ratio:1/1;border-radius:7px;overflow:hidden;background:#ece7dd;display:block;border:1px solid var(--line)}
+      .ig-cell img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .25s}
+      .ig-cell:hover img{transform:scale(1.06)}
+      .ig-cell.ig-broken{background:#ddd6c8}
+      .ig-cell .ig-vid{position:absolute;top:4px;right:5px;color:#fff;font-size:11px;text-shadow:0 1px 3px #000}
+      .ig-cell .ig-meta{position:absolute;left:0;right:0;bottom:0;padding:3px 5px;font-size:9.5px;color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.72));opacity:0;transition:opacity .2s;font-variant-numeric:tabular-nums;white-space:nowrap}
+      .ig-cell:hover .ig-meta{opacity:1}`;
+      document.head.appendChild(st);
+    }
+
+    const s = data.stats || {};
     root.querySelector('#vend-banner').innerHTML = s.missing
-      ? `<div class="muted-banner">${s.withIG} of ${s.total} brands have an official Instagram — ${s.missing} have none found (Atomic50, Bespoke, Folia, Naturale 54, Schumacher). Combined vendor reach ≈ ${fmt(s.totalReach)} followers.</div>` : '';
+      ? `<div class="muted-banner">${s.withIG} of ${s.total} brands have an official Instagram — ${s.missing} have none found (Atomic50, Bespoke, Folia, Naturale 54, Schumacher). Combined vendor reach ≈ ${fmt(s.totalReach)} followers. Last-10 posts via Instagram Business Discovery (click Refresh).</div>` : '';
   },
 };
diff --git a/scripts/probe-business-discovery.js b/scripts/probe-business-discovery.js
new file mode 100644
index 0000000..d7ee84a
--- /dev/null
+++ b/scripts/probe-business-discovery.js
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+// READ-ONLY probe: can META_ACCESS_TOKEN do Instagram business_discovery?
+// 1) find a DW-owned IG business account id via /me/accounts
+// 2) try business_discovery for one vendor handle -> last 3 posts
+const fs = require('fs');
+const path = require('path');
+const GRAPH = 'https://graph.facebook.com/v23.0';
+const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
+const TOKEN = (env.match(/^META_ACCESS_TOKEN=(.+)$/m) || [])[1];
+if (!TOKEN) { console.error('no META_ACCESS_TOKEN'); process.exit(1); }
+const TEST_HANDLE = process.argv[2] || '1838_wallcoverings';
+
+(async () => {
+  // 1) pages + IG business accounts
+  let r = await fetch(`${GRAPH}/me/accounts?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(TOKEN)}`);
+  let j = await r.json();
+  if (j.error) { console.error('me/accounts error:', JSON.stringify(j.error)); process.exit(1); }
+  const pages = (j.data || []);
+  const withIG = pages.filter(p => p.instagram_business_account);
+  console.log(`Pages: ${pages.length} | with IG business account: ${withIG.length}`);
+  if (!withIG.length) { console.error('NO IG business account linked — business_discovery impossible with this token.'); process.exit(2); }
+  const ig = withIG[0].instagram_business_account;
+  console.log(`Using discovering IG: @${ig.username} (id ${ig.id}) via Page "${withIG[0].name}"`);
+
+  // 2) business_discovery for the test vendor
+  const fields = `business_discovery.username(${TEST_HANDLE}){username,followers_count,media_count,media.limit(3){id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count}}`;
+  r = await fetch(`${GRAPH}/${ig.id}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(TOKEN)}`);
+  j = await r.json();
+  if (j.error) { console.error(`business_discovery error for ${TEST_HANDLE}:`, JSON.stringify(j.error)); process.exit(3); }
+  const bd = j.business_discovery;
+  console.log(`\n✅ business_discovery WORKS for @${bd.username}: ${bd.followers_count} followers, ${bd.media_count} posts`);
+  console.log('Last 3 media:');
+  for (const m of (bd.media?.data || [])) {
+    console.log(`  ${m.timestamp?.slice(0,10)} ${m.media_type} ♥${m.like_count ?? '?'} 💬${m.comments_count ?? '?'} ${m.permalink}`);
+    console.log(`     img: ${(m.media_url || m.thumbnail_url || '').slice(0,80)}`);
+  }
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });

← 8e94008 Channels: add Bluesky (live, app-password) + Threads (review  ·  back to Marketing Command Center  ·  Vendor IG roster: add web-confirmed @schumacher1889 (was 'no 78312e8 →