[object Object]

← back to Marketing Command Center

Make the IG account pickers real: persist handle server-side + guard follow-counts

107e329f7396dc13ebf5a98de5cd9ab29fc95aa8 · 2026-08-25 09:42:57 -0700 · Steve

Contrarian (Cody) red-team found the composer/social/quickpost account pickers
were UI-only — the selected account was sent client-side but every backend built
its persisted entry WITHOUT the handle, so the selection was silently dropped.
Fix: persist it additively (gated publish behavior untouched).
- modules/social: entry now carries handle (@ stripped) → the card renders @handle
- modules/quickpost: draft + publish entries carry handle
- modules/channels: /publish records handles[] on the outbox entry (live + staged)
- board.js: IG stream filter now matches the handles[] array, not just singular
- follow-counts.js: guard window.MCC_ACCOUNTS so a failed helper load degrades to
  the /accounts rows instead of throwing and killing the panel

Verified: POST→read-back shows handle persisted on all three; 7/7 browser smoke
green, no page errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 107e329f7396dc13ebf5a98de5cd9ab29fc95aa8
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 09:42:57 2026 -0700

    Make the IG account pickers real: persist handle server-side + guard follow-counts
    
    Contrarian (Cody) red-team found the composer/social/quickpost account pickers
    were UI-only — the selected account was sent client-side but every backend built
    its persisted entry WITHOUT the handle, so the selection was silently dropped.
    Fix: persist it additively (gated publish behavior untouched).
    - modules/social: entry now carries handle (@ stripped) → the card renders @handle
    - modules/quickpost: draft + publish entries carry handle
    - modules/channels: /publish records handles[] on the outbox entry (live + staged)
    - board.js: IG stream filter now matches the handles[] array, not just singular
    - follow-counts.js: guard window.MCC_ACCOUNTS so a failed helper load degrades to
      the /accounts rows instead of throwing and killing the panel
    
    Verified: POST→read-back shows handle persisted on all three; 7/7 browser smoke
    green, no page errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 modules/channels/index.js      |  7 +++++--
 modules/quickpost/index.js     |  4 +++-
 modules/social/index.js        |  1 +
 public/panels/board.js         |  6 +++++-
 public/panels/follow-counts.js | 12 ++++++++++--
 5 files changed, 24 insertions(+), 6 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index 909fb4a..9e4a356 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -1013,6 +1013,9 @@ module.exports = {
         const d = req.body || {};
         const channels = Array.isArray(d.channels) ? d.channels.filter(c => ADAPTERS[c]) : [];
         const selectedPages = Array.isArray(d.pages) ? d.pages.map(String).filter(Boolean) : [];
+        // DW-owned IG accounts (@handles) this post targets — recorded on the outbox
+        // entry so the selection is durable + the Board can filter a stream by account.
+        const handles = Array.isArray(d.handles) ? d.handles.map(s => String(s).replace(/^@/, '')).filter(Boolean).slice(0, 50) : [];
         const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '', videoUrl: d.videoUrl || '' };
         if (!channels.length) return res.status(400).json({ error: 'no channels selected' });
         if (!content.caption && !content.mediaUrl) return res.status(400).json({ error: 'empty post' });
@@ -1046,12 +1049,12 @@ module.exports = {
           if (live && !block) {
             const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
             const ok = r.length > 0 && r.every(x => x.ok);
-            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
+            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, handles: handles.length ? handles : undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
             results.push({ channel: ch, live: true, ok, detail: r });
           } else {
             const reason = block ? `skipped — ${block}`
               : (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
-            outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, status: reason, at: new Date().toISOString() });
+            outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, handles: handles.length ? handles : undefined, status: reason, at: new Date().toISOString() });
             results.push({ channel: ch, live: false, skipped: !!block, staged: true, reason });
           }
         }
diff --git a/modules/quickpost/index.js b/modules/quickpost/index.js
index 9537732..dd24efb 100644
--- a/modules/quickpost/index.js
+++ b/modules/quickpost/index.js
@@ -50,6 +50,7 @@ module.exports = {
       const entry = {
         id: newId(),
         platform,
+        handle: String(b.handle || '').replace(/^@/, '').slice(0, 100),  // which DW-owned IG account this draft targets
         caption: String(b.caption || '').slice(0, 2200),
         mediaUrl: String(b.mediaUrl || ''),
         source: String(b.source || 'quickpost'),
@@ -75,7 +76,8 @@ module.exports = {
       const platform = String(b.platform || '').toLowerCase();
       // (future) if channels.status[platform].connected && b.confirm === true → real post
       const entry = {
-        id: newId(), platform, caption: String(b.caption || '').slice(0, 2200),
+        id: newId(), platform, handle: String(b.handle || '').replace(/^@/, '').slice(0, 100),
+        caption: String(b.caption || '').slice(0, 2200),
         mediaUrl: String(b.mediaUrl || ''), source: String(b.source || 'quickpost'),
         status: 'draft', created_at: new Date().toISOString(),
       };
diff --git a/modules/social/index.js b/modules/social/index.js
index d4b2fa9..c417e50 100644
--- a/modules/social/index.js
+++ b/modules/social/index.js
@@ -280,6 +280,7 @@ module.exports = {
       const entry = {
         id: b.id && list.some(p => p.id === b.id) ? String(b.id) : genId(),
         channel: b.channel,
+        handle: cap((b.handle || '').replace(/^@/, ''), 100),  // which DW-owned IG account this post targets (canonical @handle)
         date: cap(b.date, 10),         // YYYY-MM-DD
         time: cap(b.time, 5),          // HH:MM
         caption: cap(b.caption, 2200), // IG caption ceiling
diff --git a/public/panels/board.js b/public/panels/board.js
index c84325e..fa092d2 100644
--- a/public/panels/board.js
+++ b/public/panels/board.js
@@ -200,7 +200,11 @@ window.MCC_PANELS['board'] = {
       let label = col.label;
       if (isIG && IG_HANDLE) {
         const want = IG_HANDLE.toLowerCase();
-        items = items.filter(it => itemHandle(it) === want);
+        // A composer post can target multiple owned accounts (it.handles[]); match
+        // either the singular handle field or membership in that array.
+        const arrHas = it => Array.isArray(it.handles) &&
+          it.handles.some(h => String(h).replace(/^@/, '').toLowerCase() === want);
+        items = items.filter(it => itemHandle(it) === want || arrHas(it));
         label = '@' + IG_HANDLE;
       }
       items = sortItems(items);
diff --git a/public/panels/follow-counts.js b/public/panels/follow-counts.js
index e8465b0..21ee4cc 100644
--- a/public/panels/follow-counts.js
+++ b/public/panels/follow-counts.js
@@ -42,9 +42,17 @@ window.MCC_PANELS['follow-counts'] = {
       // it doesn't (e.g. hosts that can't reach Norma :9810). Additive over the
       // existing /accounts response; the per-account switch + chart still fire for
       // accounts that DO have data.
-      await window.MCC_ACCOUNTS.load();
+      // Guard the canonical helper — if dw-accounts.js failed to load, degrade to
+      // just the /accounts rows rather than throwing and killing the whole panel.
+      const rows = (d && d.accounts) || [];
+      let merged;
+      if (window.MCC_ACCOUNTS) {
+        try { await window.MCC_ACCOUNTS.load(); } catch {}
+        merged = window.MCC_ACCOUNTS.merge(rows, a => a.handle);
+      } else {
+        merged = rows.map(r => ({ handle: r.handle, name: r.label || r.handle, ig_user_id: null, data: r }));
+      }
       banner(d && d.awaitingCreds, d && d.source);
-      const merged = window.MCC_ACCOUNTS.merge((d && d.accounts) || [], a => a.handle);
       if (!merged.length) { cards.innerHTML = '<div class="muted-banner">No DW Instagram accounts configured.</div>'; return; }
       cards.innerHTML = merged.map(a => {
         const row = a.data;                 // matching /accounts row, or null

← 82a9086 auto-data-snapshot: 2026-08-25T09:32:22 (1 data files) — pub  ·  back to Marketing Command Center  ·  Add per-post vendor-amplify controls (X Post / Copy kit / Am e2c5740 →