[object Object]

← back to Marketing Command Center

Channels publishing engine: FB/IG/TikTok/YouTube adapters (real Graph/TikTok API behind credential gates) + Connections+Composer panel + staged outbox until accounts authorized. The posting-machine spine.

a85f5b274a06b6b227c95830e8efe765f104587b · 2026-06-10 08:55:28 -0700 · Steve

Files touched

Diff

commit a85f5b274a06b6b227c95830e8efe765f104587b
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 10 08:55:28 2026 -0700

    Channels publishing engine: FB/IG/TikTok/YouTube adapters (real Graph/TikTok API behind credential gates) + Connections+Composer panel + staged outbox until accounts authorized. The posting-machine spine.
---
 data/channels-outbox.json   |  26 ++++++++
 modules/channels/index.js   | 142 ++++++++++++++++++++++++++++++++++++++++++++
 modules/registry.js         |   1 +
 public/panels/channels.html |  28 +++++++++
 public/panels/channels.js   |  47 +++++++++++++++
 5 files changed, 244 insertions(+)

diff --git a/data/channels-outbox.json b/data/channels-outbox.json
new file mode 100644
index 0000000..65c133a
--- /dev/null
+++ b/data/channels-outbox.json
@@ -0,0 +1,26 @@
+[
+  {
+    "id": "stg-facebook-0",
+    "channel": "facebook",
+    "caption": "New grasscloth collection just landed.",
+    "mediaUrl": "https://x/y.jpg",
+    "status": "pending-connection",
+    "at": "2026-06-10T15:55:28.807Z"
+  },
+  {
+    "id": "stg-instagram-1",
+    "channel": "instagram",
+    "caption": "New grasscloth collection just landed.",
+    "mediaUrl": "https://x/y.jpg",
+    "status": "pending-connection",
+    "at": "2026-06-10T15:55:28.807Z"
+  },
+  {
+    "id": "stg-tiktok-2",
+    "channel": "tiktok",
+    "caption": "New grasscloth collection just landed.",
+    "mediaUrl": "https://x/y.jpg",
+    "status": "pending-connection",
+    "at": "2026-06-10T15:55:28.807Z"
+  }
+]
\ No newline at end of file
diff --git a/modules/channels/index.js b/modules/channels/index.js
new file mode 100644
index 0000000..d472886
--- /dev/null
+++ b/modules/channels/index.js
@@ -0,0 +1,142 @@
+// Channels — the multi-channel PUBLISHING ENGINE. The heart of the marketing
+// machine: one composer → publishes to Facebook (pages), Instagram, TikTok,
+// YouTube. Each platform adapter holds the REAL publish call behind a credential
+// gate; with no creds the post is STAGED to the outbox (status pending-connection)
+// so the pipeline works end-to-end before any account is connected.
+//
+// LIVE posting is gated exactly like the CC sends: requires confirm:true and
+// dryRun:false; bulk/auto never posts without that. Connecting accounts (OAuth)
+// is a Steve-authorized, per-platform step done on each developer portal.
+const fs = require('fs');
+const path = require('path');
+
+const OUTBOX = path.join(__dirname, '..', '..', 'data', 'channels-outbox.json');
+const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8')); } catch { return []; } };
+const writeOutbox = (a) => { fs.mkdirSync(path.dirname(OUTBOX), { recursive: true }); fs.writeFileSync(OUTBOX, JSON.stringify(a, null, 2)); };
+const env = (k) => (process.env[k] || '').trim();
+
+// ── Per-platform connection status (what's wired vs what Steve must authorize) ──
+function platformStatus() {
+  return {
+    facebook: {
+      label: 'Facebook Pages', icon: '📘',
+      connected: !!(env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN')),
+      accounts: env('FB_PAGE_IDS') ? env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean) : [],
+      needs: 'Meta app (App ID/Secret) + per-Page access tokens with pages_manage_posts. "All your FB accounts" = paste each Page ID + token.',
+      scopes: ['pages_manage_posts', 'pages_read_engagement', 'business_management'],
+    },
+    instagram: {
+      label: 'Instagram', icon: '📸',
+      connected: !!(env('META_APP_ID') && env('IG_ACCESS_TOKEN') && env('IG_BUSINESS_ACCOUNT_ID')),
+      accounts: env('IG_BUSINESS_ACCOUNT_ID') ? [env('IG_BUSINESS_ACCOUNT_ID')] : [],
+      needs: 'IG Business account linked to a FB Page + long-lived token with instagram_content_publish (same Meta app as Facebook).',
+      scopes: ['instagram_content_publish', 'instagram_basic'],
+    },
+    tiktok: {
+      label: 'TikTok', icon: '🎵',
+      connected: !!(env('TIKTOK_ACCESS_TOKEN')),
+      accounts: [],
+      needs: 'TikTok for Developers app + Content Posting API access (audited) → user OAuth → access token. Direct-post needs app review; sandbox posts to private only.',
+      scopes: ['video.publish', 'video.upload'],
+    },
+    youtube: {
+      label: 'YouTube', icon: '▶️',
+      connected: !!(env('YOUTUBE_ACCESS_TOKEN') || (env('GOOGLE_CLIENT_ID') && env('YOUTUBE_REFRESH_TOKEN'))),
+      accounts: [],
+      needs: 'Google Cloud project + YouTube Data API v3 enabled + OAuth consent (youtube.upload scope) → channel refresh token.',
+      scopes: ['https://www.googleapis.com/auth/youtube.upload'],
+    },
+  };
+}
+
+// ── Real adapters (fire only when connected) ──────────────────────────────────
+async function postFacebook(content) {
+  const token = env('FB_PAGE_ACCESS_TOKEN'); const pages = env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean);
+  const results = [];
+  for (const pageId of pages) {
+    const url = `https://graph.facebook.com/v21.0/${pageId}/${content.mediaUrl ? 'photos' : 'feed'}`;
+    const body = content.mediaUrl ? { url: content.mediaUrl, caption: content.caption, access_token: token }
+                                  : { message: content.caption, access_token: token };
+    const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
+    const j = await r.json().catch(() => ({}));
+    results.push({ pageId, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
+  }
+  return results;
+}
+async function postInstagram(content) {
+  const token = env('IG_ACCESS_TOKEN'); const igId = env('IG_BUSINESS_ACCOUNT_ID');
+  if (!content.mediaUrl) return [{ ok: false, error: 'Instagram requires an image/video URL' }];
+  const create = await fetch(`https://graph.facebook.com/v21.0/${igId}/media`, {
+    method: 'POST', headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ image_url: content.mediaUrl, caption: content.caption, access_token: token }),
+  });
+  const cj = await create.json().catch(() => ({}));
+  if (!create.ok || !cj.id) return [{ ok: false, error: cj.error?.message || 'media create failed' }];
+  const pub = await fetch(`https://graph.facebook.com/v21.0/${igId}/media_publish`, {
+    method: 'POST', headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ creation_id: cj.id, access_token: token }),
+  });
+  const pj = await pub.json().catch(() => ({}));
+  return [{ ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'publish failed') }];
+}
+async function postTikTok(content) {
+  // TikTok Content Posting API (direct post). Requires audited app.
+  const token = env('TIKTOK_ACCESS_TOKEN');
+  const r = await fetch('https://open.tiktokapis.com/v2/post/publish/video/init/', {
+    method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
+    body: JSON.stringify({ post_info: { title: content.caption, privacy_level: 'SELF_ONLY' }, source_info: { source: 'PULL_FROM_URL', video_url: content.mediaUrl } }),
+  });
+  const j = await r.json().catch(() => ({}));
+  return [{ ok: r.ok && !j.error?.code, id: j.data?.publish_id, error: r.ok ? null : JSON.stringify(j.error || {}) }];
+}
+async function postYouTube() {
+  // videos.insert is a resumable multipart upload — needs the binary; out of scope
+  // for URL-only v1. Staged with a clear note until the upload pipeline is wired.
+  return [{ ok: false, error: 'YouTube upload pipeline not wired (needs resumable binary upload)' }];
+}
+const ADAPTERS = { facebook: postFacebook, instagram: postInstagram, tiktok: postTikTok, youtube: postYouTube };
+
+module.exports = {
+  id: 'channels',
+  title: 'Channels & Publish',
+  icon: '📡',
+  mount(router) {
+    router.get('/status', (_req, res) => {
+      const st = platformStatus();
+      const connected = Object.values(st).filter(p => p.connected).length;
+      res.json({ platforms: st, connectedCount: connected, total: Object.keys(st).length, outbox: readOutbox().length });
+    });
+    router.get('/outbox', (_req, res) => res.json({ items: readOutbox().slice(-100).reverse() }));
+
+    // The publish pipeline. Stages by default; only fires live with confirm + !dryRun
+    // AND a connected platform. Unconnected platforms always stage.
+    router.post('/publish', async (req, res) => {
+      try {
+        const d = req.body || {};
+        const channels = Array.isArray(d.channels) ? d.channels.filter(c => ADAPTERS[c]) : [];
+        const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '' };
+        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' });
+        const st = platformStatus();
+        const live = d.confirm === true && d.dryRun === false;
+        const outbox = readOutbox();
+        const results = [];
+        for (const ch of channels) {
+          const connected = st[ch].connected;
+          if (live && connected) {
+            const r = await ADAPTERS[ch](content).catch(e => [{ ok: false, error: e.message }]);
+            const ok = r.every(x => x.ok);
+            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
+            results.push({ channel: ch, live: true, ok, detail: r });
+          } else {
+            const reason = !connected ? 'pending-connection' : (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() });
+            results.push({ channel: ch, live: false, staged: true, reason });
+          }
+        }
+        writeOutbox(outbox);
+        res.json({ ok: true, live, results, note: live ? 'Live posts fired to connected channels; unconnected staged.' : 'Staged only — connect channels + confirm with dryRun:false to post live.' });
+      } catch (e) { res.status(500).json({ error: e.message }); }
+    });
+  },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 504c03a..565bc01 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -16,4 +16,5 @@ module.exports = [
   'social',             // social scheduler: IG/TikTok queue board, gated publish
   'segments',           // audience segments: rule builder + live preview over contacts
   'vendors',            // vendor IG reporting: DW + all vendor Instagram accounts roster
+  'channels',           // publishing engine: FB/IG/TikTok/YouTube connectors + composer (gated live posts)
 ];
diff --git a/public/panels/channels.html b/public/panels/channels.html
new file mode 100644
index 0000000..8f9e60f
--- /dev/null
+++ b/public/panels/channels.html
@@ -0,0 +1,28 @@
+<div class="muted-banner" id="ch-banner">Loading channel status…</div>
+<div class="card">
+  <h2>Connected channels</h2>
+  <div class="muted" style="margin-bottom:12px">The publishing engine posts to every connected account at once. Connect each platform to go live; until then posts stage in the outbox.</div>
+  <div class="grid" id="ch-platforms" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))"></div>
+</div>
+<div class="card">
+  <h2>Composer</h2>
+  <div class="row">
+    <div style="flex:1;min-width:280px">
+      <label>Caption</label>
+      <textarea id="ch-caption" rows="4" placeholder="Write the post… (use the Copy panel for AI captions)"></textarea>
+      <label style="margin-top:10px">Image / video URL (required for IG · TikTok)</label>
+      <input type="text" id="ch-media" placeholder="https://… (use the Layouts or Assets panel)">
+      <label style="margin-top:10px">Post to</label>
+      <div class="row" id="ch-targets"></div>
+      <div class="row" style="margin-top:14px;align-items:center">
+        <button class="btn gold" id="ch-stage">Stage post</button>
+        <button class="btn gated" id="ch-publish">🚀 Publish live…</button>
+        <span class="muted" id="ch-msg"></span>
+      </div>
+    </div>
+  </div>
+</div>
+<div class="card">
+  <h2>Outbox</h2>
+  <div id="ch-outbox" class="muted">No posts yet.</div>
+</div>
diff --git a/public/panels/channels.js b/public/panels/channels.js
new file mode 100644
index 0000000..de8a324
--- /dev/null
+++ b/public/panels/channels.js
@@ -0,0 +1,47 @@
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['channels'] = {
+  async init(root) {
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const $ = s => root.querySelector(s);
+    let status = {};
+    async function loadStatus() {
+      status = await (await fetch('/api/channels/status')).json();
+      const ps = status.platforms || {};
+      $('#ch-banner').innerHTML = `${status.connectedCount}/${status.total} channels connected · ${status.outbox} in outbox. ` +
+        (status.connectedCount === 0 ? 'No channels connected yet — authorize each platform to enable live posting.' : 'Connected channels post live; the rest stage.');
+      $('#ch-platforms').innerHTML = Object.entries(ps).map(([k, p]) => `
+        <div class="card" style="margin:0;border-color:${p.connected ? 'var(--sage)' : 'var(--line)'}">
+          <div style="display:flex;align-items:center;gap:8px"><span style="font-size:20px">${p.icon}</span><b>${esc(p.label)}</b>
+            <span class="pill" style="margin-left:auto;background:${p.connected ? '#e3efe0' : '#f3e9e9'};color:${p.connected ? '#3a6b3a' : '#9a5a5a'}">${p.connected ? 'connected' : 'not connected'}</span></div>
+          ${p.accounts && p.accounts.length ? `<div class="muted" style="margin-top:6px;font-size:12px">${p.accounts.length} account(s)</div>` : ''}
+          ${!p.connected ? `<div class="muted" style="margin-top:8px;font-size:11.5px"><b>To connect:</b> ${esc(p.needs)}</div>` : ''}
+        </div>`).join('');
+      // targets
+      $('#ch-targets').innerHTML = Object.entries(ps).map(([k, p]) =>
+        `<label style="display:flex;align-items:center;gap:6px;font-weight:600;color:var(--ink)"><input type="checkbox" class="ch-t" value="${k}" style="width:auto"> ${p.icon} ${esc(p.label)}${p.connected ? '' : ' <span class="muted" style="font-weight:400">(stages)</span>'}</label>`).join('');
+    }
+    async function loadOutbox() {
+      const d = await (await fetch('/api/channels/outbox')).json();
+      $('#ch-outbox').innerHTML = (d.items || []).length ? d.items.map(it => `
+        <div class="row" style="justify-content:space-between;border-bottom:1px solid var(--line);padding:7px 2px">
+          <span>${esc(it.channel)} · <span class="muted">${esc((it.caption || '').slice(0, 50))}</span></span>
+          <span class="pill">${esc(it.status)}</span></div>`).join('') : '<div class="muted">No posts yet.</div>';
+    }
+    async function send(live) {
+      const channels = [...root.querySelectorAll('.ch-t:checked')].map(c => c.value);
+      const caption = $('#ch-caption').value.trim(); const mediaUrl = $('#ch-media').value.trim();
+      if (!channels.length) { $('#ch-msg').textContent = 'pick at least one channel'; return; }
+      if (!caption && !mediaUrl) { $('#ch-msg').textContent = 'write a caption or add media'; return; }
+      if (live && !confirm(`PUBLISH LIVE to: ${channels.join(', ')}?\n\nConnected channels post immediately; unconnected ones stage. Proceed?`)) return;
+      $('#ch-msg').textContent = live ? 'publishing…' : 'staging…';
+      const body = { channels, caption, mediaUrl, confirm: live, dryRun: !live };
+      const r = await fetch('/api/channels/publish', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
+      const d = await r.json();
+      $('#ch-msg').textContent = d.error ? '✗ ' + d.error : (d.note || 'done');
+      loadOutbox(); loadStatus();
+    }
+    $('#ch-stage').onclick = () => send(false);
+    $('#ch-publish').onclick = () => send(true);
+    await loadStatus(); await loadOutbox();
+  },
+};

← dc913ce Vendor IG Reporting panel: DW + 37 vendor Instagram accounts  ·  back to Marketing Command Center  ·  Copy panel: Gemini path (preferred over dead Anthropic) → re b546fbd →