[object Object]

← back to Marketing Command Center

ig-activity: IG Posts panel — show all fleet IG posts + per-post delete proxied to Norma's gated/serialized delete

4fd1cb42362a31f2899b7ab3c961221f7698a61d · 2026-08-17 07:35:12 -0700 · Steve Abrams

Files touched

Diff

commit 4fd1cb42362a31f2899b7ab3c961221f7698a61d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 17 07:35:12 2026 -0700

    ig-activity: IG Posts panel — show all fleet IG posts + per-post delete proxied to Norma's gated/serialized delete
---
 .deploy.conf                   |   2 +-
 modules/ig-activity/index.js   | 116 ++++++++++++++++++++++++++++++
 modules/registry.js            |   1 +
 public/panels/ig-activity.html |  57 +++++++++++++++
 public/panels/ig-activity.js   | 157 +++++++++++++++++++++++++++++++++++++++++
 5 files changed, 332 insertions(+), 1 deletion(-)

diff --git a/.deploy.conf b/.deploy.conf
index 3ec5da4..4a5a9c6 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -23,4 +23,4 @@ HEALTH_URL=http://127.0.0.1:9662/api/health
 # the deploy-side guard regardless.)
 # linkedin-feed.json is the openclaw harvest output (TK-10504) — generated on the
 # remote by the running MCC; a stale/absent local copy must never clobber it.
-RSYNC_EXTRA_EXCLUDES="quickpost-drafts.json follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json linkedin-feed-curated.json renders"
+RSYNC_EXTRA_EXCLUDES="quickpost-drafts.json follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json linkedin-feed-curated.json ig-activity-tombstones.json renders"
diff --git a/modules/ig-activity/index.js b/modules/ig-activity/index.js
new file mode 100644
index 0000000..f9388d6
--- /dev/null
+++ b/modules/ig-activity/index.js
@@ -0,0 +1,116 @@
+// IG Posts — show every Instagram post the DW fleet has published (from the
+// gen-ig-activity snapshot at public/ig-activity.json) and delete one.
+//
+// DELETE REALITY (do not "fix" this into a Graph call): the Instagram Graph API
+// CANNOT delete a published post. The only working delete is Norma's proven
+// openclaw real-Chrome flow (open post → ··· → Delete → verify 404), which is
+// PACED + SERIALIZED because concurrent deletes are Meta's #1 fleet-ban trigger.
+// So this module NEVER deletes directly — it PROXIES to Norma's own gated
+// endpoint (POST /api/posts/delete on :9810), which already enforces:
+//   • live:false (default) → tombstone only: drop from the ledger, IG untouched.
+//   • live:true            → real delete, but gated behind IG_LIVE_DELETE=1 on
+//                            Norma AND serialized (one at a time / 409 if busy),
+//                            tombstoned ONLY on a verified 404.
+// We keep a local tombstone mirror so a removed post disappears from THIS board
+// immediately, even before the next snapshot regen.
+//
+// Self-contained per the MODULE CONTRACT. Reuses the shared timeout guard and the
+// same NORMA_IG_* env the follow-counts module already established.
+const fs = require('fs');
+const path = require('path');
+const { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
+
+const ACTIVITY = path.join(__dirname, '..', '..', 'public', 'ig-activity.json');
+const TOMBSTONES = path.join(__dirname, '..', '..', 'data', 'ig-activity-tombstones.json');
+
+// ── Norma instagram-agent (reuse its plumbing/creds — see follow-counts) ──────
+const NORMA_BASE = (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replace(/\/$/, '');
+const NORMA_USER = process.env.NORMA_IG_USER || 'admin';
+const NORMA_PASS = process.env.NORMA_IG_PASS || '';
+const normaAuth = 'Basic ' + Buffer.from(`${NORMA_USER}:${NORMA_PASS}`).toString('base64');
+
+function loadActivity() {
+  try { return JSON.parse(fs.readFileSync(ACTIVITY, 'utf8')); }
+  catch { return { posts: [], total_posts: 0, accounts_touched: 0, last_post_at: null, generated_at: null }; }
+}
+function loadTombstones() {
+  try { return new Set(JSON.parse(fs.readFileSync(TOMBSTONES, 'utf8'))); }
+  catch { return new Set(); }
+}
+function saveTombstones(set) {
+  fs.mkdirSync(path.dirname(TOMBSTONES), { recursive: true });
+  fs.writeFileSync(TOMBSTONES, JSON.stringify([...set], null, 2));
+}
+// A post's stable identity — permalink first (what Norma deletes on), media_id fallback.
+const postKey = p => (p && (p.permalink || p.media_id)) || '';
+
+module.exports = {
+  id: 'ig-activity',
+  title: 'IG Posts',
+  icon: '📸',
+  mount(router) {
+    // All posts (minus anything locally tombstoned), newest snapshot metadata.
+    router.get('/posts', (_req, res) => {
+      const j = loadActivity();
+      const gone = loadTombstones();
+      const posts = (j.posts || []).filter(p => !gone.has(postKey(p)));
+      res.json({
+        posts,
+        total_posts: posts.length,
+        removed: gone.size,
+        accounts_touched: new Set(posts.map(p => p.handle)).size,
+        last_post_at: j.last_post_at || null,
+        generated_at: j.generated_at || null,
+      });
+    });
+
+    // Is Norma's real-Chrome session logged in + is live delete armed? (advisory
+    // for the UI so it can tell the user whether a "real" delete will fire or 403.)
+    router.get('/delete-status', async (_req, res) => {
+      try {
+        const r = await fetchWithTimeout(`${NORMA_BASE}/api/posts/oc-status`,
+          { headers: { Authorization: normaAuth } }, 8000);
+        const j = await r.json().catch(() => ({}));
+        res.json({ ok: true, loggedIn: !!j.loggedIn, norma: NORMA_BASE });
+      } catch (e) {
+        res.json({ ok: false, loggedIn: false, error: `Norma unreachable: ${e.message}`, norma: NORMA_BASE });
+      }
+    });
+
+    // Delete a post. Proxies to Norma's gated/serialized endpoint. Default is
+    // tombstone (live:false) — safe, never touches Instagram. live:true asks for
+    // the real delete, which Norma itself gates (IG_LIVE_DELETE) + serializes.
+    router.post('/delete', async (req, res) => {
+      const { permalink = '', media_id = '', handle = '', live = false } = req.body || {};
+      const id = permalink || media_id;
+      if (!id) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
+      try {
+        const r = await fetchWithTimeout(`${NORMA_BASE}/api/posts/delete`, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json', Authorization: normaAuth },
+          body: JSON.stringify({ permalink, media_id, handle, live: !!live }),
+        }, live ? 120000 : 15000); // a real openclaw delete is slow; tombstone is fast
+        const j = await r.json().catch(() => ({}));
+        // Mirror to our local tombstone so it leaves THIS board immediately, on any
+        // outcome Norma treats as removed (tombstone mode, or verified live delete).
+        if (r.ok && j.ok && (j.mode === 'tombstone' || j.verified)) {
+          const set = loadTombstones(); set.add(permalink || media_id); saveTombstones(set);
+        }
+        return res.status(r.ok ? 200 : r.status).json(j);
+      } catch (e) {
+        return res.status(502).json({ ok: false, error: `Norma delete failed: ${e.message}` });
+      }
+    });
+
+    // Undo a local tombstone (does NOT resurrect on Instagram — only un-hides from
+    // this board; only meaningful for tombstone-mode removals).
+    router.post('/restore', (req, res) => {
+      const { permalink = '', media_id = '' } = req.body || {};
+      const key = permalink || media_id;
+      if (!key) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
+      const set = loadTombstones();
+      const had = set.delete(key); saveTombstones(set);
+      res.json({ ok: true, restored: had });
+    });
+  },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 3297203..196f7bf 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -28,6 +28,7 @@ module.exports = [
   'clients',            // Clients & Prospects: FileMaker client DB (43k) + Google-Places prospect groups, LinkedIn+Instagram discovery + emails (staged; not on CC)
   'segments',           // audience segments: rule builder + live preview over contacts
   'vendors',            // vendor IG reporting: DW + all vendor Instagram accounts roster
+  'ig-activity',        // IG Posts: every fleet IG post (from gen-ig-activity snapshot) + per-post delete proxied to Norma's gated/serialized real-Chrome delete (:9810)
   'channels',           // publishing engine: FB/IG/TikTok/YouTube connectors + composer (gated live posts)
   'send-times',         // send-time optimization: heatmap + per-contact predicted peak open window
   'ab-tests',           // A/B test planner: two variants + holdout, predicted opens/clicks, winner call (STAGED)
diff --git a/public/panels/ig-activity.html b/public/panels/ig-activity.html
new file mode 100644
index 0000000..191e862
--- /dev/null
+++ b/public/panels/ig-activity.html
@@ -0,0 +1,57 @@
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap">
+    <div>
+      <h2>IG Posts</h2>
+      <div class="muted" style="font-size:12px;max-width:680px">
+        Every post the DW Instagram fleet has published (from the live activity snapshot).
+        Deleting routes through Norma's serialized, ban-safe real-Chrome flow —
+        the Instagram Graph API cannot delete a post. <b>Remove from board</b> just
+        hides it here; <b>Delete on Instagram</b> is the real, gated delete.
+      </div>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <button class="btn ghost" id="ig-refresh" style="padding:6px 12px;font-size:12.5px">Refresh</button>
+    </div>
+  </div>
+  <div class="row" style="gap:18px;align-items:center;flex-wrap:wrap;margin-top:12px">
+    <div class="row" style="gap:8px;align-items:center">
+      <span class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.06em">Sort</span>
+      <select id="ig-sort" class="btn ghost" style="padding:6px 10px;font-size:12.5px">
+        <option value="ts|desc">Newest</option>
+        <option value="ts|asc">Oldest</option>
+        <option value="handle|asc">Account A→Z</option>
+        <option value="handle|desc">Account Z→A</option>
+        <option value="product_title|asc">Product A→Z</option>
+        <option value="product_title|desc">Product Z→A</option>
+        <option value="kind|asc">Type A→Z</option>
+      </select>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <span class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.06em">Account</span>
+      <select id="ig-acct" class="btn ghost" style="padding:6px 10px;font-size:12.5px"><option value="">All</option></select>
+    </div>
+    <div class="row" style="gap:8px;align-items:center">
+      <span class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.06em">Density</span>
+      <input type="range" id="ig-density" min="2" max="7" step="1" style="accent-color:var(--gold,#c9a86a)">
+    </div>
+    <div class="muted" id="ig-meta" style="font-size:12px;margin-left:auto"></div>
+  </div>
+</div>
+
+<div id="ig-grid" class="grid" style="grid-template-columns:repeat(var(--ig-cols,4),1fr);align-items:start">
+  <div class="loading">Loading posts…</div>
+</div>
+
+<!-- delete confirm modal -->
+<div id="ig-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:60;align-items:center;justify-content:center;padding:20px">
+  <div class="card" style="max-width:440px;width:100%">
+    <h2 id="ig-modal-title" style="margin-top:0">Delete this Instagram post?</h2>
+    <div id="ig-modal-body" class="muted" style="font-size:13px;line-height:1.55"></div>
+    <div id="ig-modal-status" style="font-size:12.5px;margin-top:10px"></div>
+    <div class="row" style="gap:8px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap">
+      <button class="btn ghost" id="ig-cancel" style="padding:7px 14px;font-size:12.5px">Cancel</button>
+      <button class="btn" id="ig-remove" style="padding:7px 14px;font-size:12.5px">Remove from board</button>
+      <button class="btn" id="ig-live" style="padding:7px 14px;font-size:12.5px;background:#b3261e;border-color:#b3261e;color:#fff">Delete on Instagram</button>
+    </div>
+  </div>
+</div>
diff --git a/public/panels/ig-activity.js b/public/panels/ig-activity.js
new file mode 100644
index 0000000..1c004f3
--- /dev/null
+++ b/public/panels/ig-activity.js
@@ -0,0 +1,157 @@
+// IG Posts panel — show every fleet Instagram post with sort + account filter +
+// density (persisted), each card stamped with its post date+time, and a per-post
+// delete that routes through Norma's gated/serialized flow via /api/ig-activity.
+// "Remove from board" = tombstone (safe, IG untouched); "Delete on Instagram" =
+// the real, gated delete (Norma 403s it unless armed, 409s if one's already running).
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['ig-activity'] = {
+  init(root) {
+    const $ = s => root.querySelector(s);
+    const api = (p, opts) => fetch(`/api/ig-activity${p}`, opts).then(r => r.json());
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+    const fmtTime = t => { try { return new Date(t).toLocaleString(undefined,
+      { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return t || ''; } };
+
+    let DATA = [];
+    let SORT = localStorage.getItem('ig_sort') || 'ts|desc';
+    let ACCT = localStorage.getItem('ig_acct') || '';
+    let target = null; // the post being deleted (modal context)
+
+    $('#ig-sort').value = SORT;
+    const den = localStorage.getItem('ig_density') || '4';
+    $('#ig-density').value = den;
+    root.style.setProperty('--ig-cols', den);
+    // panels render into a container; set the var on that container too
+    const host = root.closest('[data-panel]') || document.documentElement;
+    host.style.setProperty('--ig-cols', den);
+
+    function filtered() {
+      let list = DATA.slice();
+      if (ACCT) list = list.filter(x => x.handle === ACCT);
+      const [k, dir] = SORT.split('|');
+      list.sort((a, b) => {
+        let x = a[k], y = b[k];
+        if (k === 'ts') { x = new Date(x); y = new Date(y); }
+        else { x = (x || '').toString().toLowerCase(); y = (y || '').toString().toLowerCase(); }
+        return (x < y ? -1 : x > y ? 1 : 0) * (dir === 'desc' ? -1 : 1);
+      });
+      return list;
+    }
+
+    function card(x) {
+      const img = esc((x.image_url || '').replace(/'/g, ''));
+      const link = esc(x.permalink || '#');
+      return `<div class="card" style="padding:0;overflow:hidden;display:flex;flex-direction:column">
+        <a href="${link}" target="_blank" rel="noopener noreferrer" style="display:block;aspect-ratio:1/1;background:#111 center/cover no-repeat;background-image:url('${img}')"></a>
+        <div style="padding:11px 12px 12px;display:flex;flex-direction:column;gap:6px;flex:1">
+          <a class="acct" href="https://www.instagram.com/${esc(x.handle)}/" target="_blank" rel="noopener noreferrer"
+             style="color:var(--gold,#c9a86a);font-weight:600;font-size:13px;text-decoration:none">@${esc(x.handle)}</a>
+          <div style="font-size:12.5px;line-height:1.35;min-height:2.4em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden"
+               title="${esc(x.product_title)}">${esc(x.product_title) || '—'}</div>
+          <div class="muted" style="font-size:11px" title="${esc(x.ts)}">🕓 ${fmtTime(x.ts)} · ${esc(x.kind || 'IMAGE')}</div>
+          <div class="row" style="gap:6px;justify-content:space-between;align-items:center;margin-top:auto;padding-top:8px">
+            <a class="btn ghost" href="${link}" target="_blank" rel="noopener noreferrer" style="padding:4px 10px;font-size:11.5px">View ↗</a>
+            <button class="btn ig-del" data-key="${esc(x.permalink || x.media_id)}" style="padding:4px 10px;font-size:11.5px;color:#b3261e;border-color:#e3b7b3">Delete</button>
+          </div>
+        </div>
+      </div>`;
+    }
+
+    function render() {
+      const list = filtered();
+      $('#ig-grid').innerHTML = list.length
+        ? list.map(card).join('')
+        : '<div class="loading">No posts.</div>';
+      $('#ig-grid').querySelectorAll('.ig-del').forEach(b =>
+        b.onclick = () => openModal(b.dataset.key));
+    }
+
+    function fillAccounts() {
+      const accts = [...new Set(DATA.map(x => x.handle))].sort();
+      const sel = $('#ig-acct');
+      sel.innerHTML = '<option value="">All accounts</option>' +
+        accts.map(a => `<option value="${esc(a)}">@${esc(a)}</option>`).join('');
+      sel.value = ACCT;
+    }
+
+    // ── delete modal ──────────────────────────────────────────────────────────
+    function openModal(key) {
+      target = DATA.find(x => (x.permalink || x.media_id) === key);
+      if (!target) return;
+      $('#ig-modal-title').textContent = 'Delete this Instagram post?';
+      $('#ig-modal-body').innerHTML =
+        `<b>@${esc(target.handle)}</b> — ${esc(target.product_title) || 'post'}<br>` +
+        `<span style="font-size:12px">${esc(target.permalink || '')}</span><br><br>` +
+        `<b>Remove from board</b> hides it here only (Instagram untouched). ` +
+        `<b>Delete on Instagram</b> is the real, permanent delete — it runs through ` +
+        `Norma's serialized real-Chrome flow and only fires if live delete is armed on Norma.`;
+      $('#ig-modal-status').textContent = '';
+      setBusy(false);
+      $('#ig-modal').style.display = 'flex';
+    }
+    function closeModal() { $('#ig-modal').style.display = 'none'; target = null; }
+    function setBusy(b) {
+      ['#ig-remove', '#ig-live', '#ig-cancel'].forEach(s => $(s).disabled = b);
+    }
+
+    async function doDelete(live) {
+      if (!target) return;
+      setBusy(true);
+      $('#ig-modal-status').innerHTML = `<span class="muted">${live ? 'Deleting on Instagram (this can take ~a minute)…' : 'Removing…'}</span>`;
+      try {
+        const j = await api('/delete', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ permalink: target.permalink, media_id: target.media_id, handle: target.handle, live }),
+        });
+        if (j.ok && (j.mode === 'tombstone' || j.verified)) {
+          const key = target.permalink || target.media_id;
+          DATA = DATA.filter(x => (x.permalink || x.media_id) !== key);
+          closeModal(); fillAccounts(); render(); updateMeta();
+        } else {
+          $('#ig-modal-status').innerHTML = `<span style="color:#b3261e">${esc(j.error || 'Delete did not complete.')}</span>`;
+          setBusy(false);
+        }
+      } catch (e) {
+        $('#ig-modal-status').innerHTML = `<span style="color:#b3261e">${esc(e.message)}</span>`;
+        setBusy(false);
+      }
+    }
+
+    function updateMeta() {
+      $('#ig-meta').textContent =
+        `${DATA.length} posts · ${new Set(DATA.map(x => x.handle)).size} accounts`;
+    }
+
+    // ── wiring ────────────────────────────────────────────────────────────────
+    $('#ig-sort').onchange = () => { SORT = $('#ig-sort').value; localStorage.setItem('ig_sort', SORT); render(); };
+    $('#ig-acct').onchange = () => { ACCT = $('#ig-acct').value; localStorage.setItem('ig_acct', ACCT); render(); };
+    $('#ig-density').oninput = () => {
+      const v = $('#ig-density').value;
+      localStorage.setItem('ig_density', v);
+      root.style.setProperty('--ig-cols', v);
+      host.style.setProperty('--ig-cols', v);
+    };
+    $('#ig-refresh').onclick = load;
+    $('#ig-cancel').onclick = closeModal;
+    $('#ig-remove').onclick = () => doDelete(false);
+    $('#ig-live').onclick = () => doDelete(true);
+    $('#ig-modal').onclick = e => { if (e.target === $('#ig-modal')) closeModal(); };
+
+    async function load() {
+      $('#ig-grid').innerHTML = '<div class="loading">Loading posts…</div>';
+      try {
+        const j = await api('/posts');
+        DATA = j.posts || [];
+        $('#ig-meta').textContent =
+          `${j.total_posts ?? DATA.length} posts · ${j.accounts_touched ?? new Set(DATA.map(x => x.handle)).size} accounts` +
+          (j.generated_at ? ` · updated ${fmtTime(j.generated_at)}` : '') +
+          (j.removed ? ` · ${j.removed} removed` : '');
+        fillAccounts(); render();
+      } catch (e) {
+        $('#ig-grid').innerHTML = `<div class="loading">Failed to load: ${esc(e.message)}</div>`;
+      }
+    }
+    load();
+  },
+};

← 0c84eb5 copy: consolidate onto shared lib/fetch-timeout.js (drop dup  ·  back to Marketing Command Center  ·  ig-activity: honest banner when Norma delete-host unreachabl 679a39e →