[object Object]

← back to Marketing Command Center

channels: add honest 'postable' status — connected != can-actually-post

69af9fe868d192f4b5c0657f01a4c15d5bba0a01 · 2026-07-22 07:33:09 -0700 · Steve Abrams

The status endpoint reported connected:true for channels that cannot post
(Facebook needs pages_manage_posts App Review; LinkedIn token is unscoped/dead;
TikTok/YouTube are video-only), which misled callers twice. Added a POSTABILITY
map encoding the known structural blocks and surface per channel:
  postable     — connected AND not structurally blocked (the real signal)
  mediaTypes   — what it accepts (image caller must check; TikTok/YouTube = video-only)
  postableNote — the why (App Review / dead token / video-only)
Also corrected the stale Threads caveat (tester-mode posts live without App Review).

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

Files touched

Diff

commit 69af9fe868d192f4b5c0657f01a4c15d5bba0a01
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 07:33:09 2026 -0700

    channels: add honest 'postable' status — connected != can-actually-post
    
    The status endpoint reported connected:true for channels that cannot post
    (Facebook needs pages_manage_posts App Review; LinkedIn token is unscoped/dead;
    TikTok/YouTube are video-only), which misled callers twice. Added a POSTABILITY
    map encoding the known structural blocks and surface per channel:
      postable     — connected AND not structurally blocked (the real signal)
      mediaTypes   — what it accepts (image caller must check; TikTok/YouTube = video-only)
      postableNote — the why (App Review / dead token / video-only)
    Also corrected the stale Threads caveat (tester-mode posts live without App Review).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 modules/channels/index.js | 31 +++++++++++++++++++++++++++++--
 1 file changed, 29 insertions(+), 2 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index 581597b..0b1dc63 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -234,6 +234,26 @@ async function exchangeLongLivedMeta(shortToken) {
   return j.access_token;
 }
 
+// Real-world posting capability. `connected` (creds present) is NOT the same as
+// "will actually post" — a channel can be connected yet blocked by App Review, a
+// dead token, or a media-type mismatch. This encodes the KNOWN structural blocks
+// (verified in production) so the status endpoint stops advertising a non-postable
+// channel as ready. `postable`: can it publish anything right now? `mediaTypes`:
+// what it will accept (an image caller must check this). `postableNote`: the why.
+const POSTABILITY = {
+  bluesky:   { postable: true,  mediaTypes: ['image', 'text'] },
+  threads:   { postable: true,  mediaTypes: ['image', 'text'] },   // DW is a Meta tester → publishes without App Review (proven)
+  instagram: { postable: true,  mediaTypes: ['image', 'video'] },
+  youtube:   { postable: true,  mediaTypes: ['video'],
+               postableNote: 'Video uploads only — image/text posts are not supported.' },
+  tiktok:    { postable: true,  mediaTypes: ['video'],
+               postableNote: 'Private-only (SELF_ONLY) and video-only until TikTok App Review — cannot post publicly or post an image.' },
+  facebook:  { postable: false, mediaTypes: ['image', 'video'],
+               postableNote: 'Blocked: page posting needs pages_manage_posts, which requires Meta App Review — API posts fail until approved.' },
+  linkedin:  { postable: false, mediaTypes: ['image', 'text'],
+               postableNote: 'Blocked: company-page posting needs a dedicated Community-Management app; the current token is unscoped/invalid.' },
+};
+
 // ── Per-platform connection status (what's wired vs what Steve must authorize) ──
 function platformStatus() {
   const tk = readTokens();
@@ -289,9 +309,9 @@ function platformStatus() {
       label: 'Threads', icon: '🧵',
       connected: !!(env('THREADS_ACCESS_TOKEN') && env('THREADS_USER_ID')),
       accounts: env('THREADS_USER_ID') ? [env('THREADS_USER_ID')] : [],
-      needs: 'Threads user id + access token with threads_content_publish (Meta App Review gates publish, like FB/IG).',
+      needs: 'Threads user id + access token with threads_content_publish.',
       scopes: ['threads_basic', 'threads_content_publish'],
-      caveat: 'threads_content_publish needs Meta App Review before live posting; until then posts stage.',
+      caveat: 'DW is a Meta tester → threads_content_publish posts live WITHOUT App Review (verified). Public rollout still needs review, but tester posting works now.',
     },
     linkedin: {
       label: 'LinkedIn', icon: '💼',
@@ -310,6 +330,13 @@ function platformStatus() {
     base[p].configured = cfg(p) || base[p].connected;
     base[p].connected = base[p].connected || tokenConnected(p);
     base[p].connectUrl = (cfg(p) && !base[p].connected) ? `/api/channels/connect/${p}` : null;
+    // postable = connected AND not structurally blocked. This is the honest
+    // "will it actually post" signal; `connected` alone over-promises.
+    const cap = POSTABILITY[p] || { postable: base[p].connected, mediaTypes: ['image', 'text'] };
+    base[p].postable = base[p].connected && cap.postable;
+    base[p].mediaTypes = cap.mediaTypes;
+    if (cap.postableNote) base[p].postableNote = cap.postableNote;
+    else if (!base[p].connected) base[p].postableNote = 'Not connected — add credentials first.';
   }
   return base;
 }

← d84dc2a engine: untrack runtime engine-queue.json + protect it on de  ·  back to Marketing Command Center  ·  channels: /publish skips non-postable + media-mismatched cha 29d1ff2 →