← 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
M modules/channels/index.jsM modules/quickpost/index.jsM modules/social/index.jsM public/panels/board.jsM public/panels/follow-counts.js
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 →