← 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
M .deploy.confA modules/ig-activity/index.jsM modules/registry.jsA public/panels/ig-activity.htmlA public/panels/ig-activity.js
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 =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →