[object Object]

← back to Marketing Command Center

Social: real approval pipeline + instant posting + truthful live/simulated UX

88a51a704fad2b25d7ea6a5f1099a238366f7508 · 2026-06-22 09:57:11 -0700 · Steve

Audit found posts were 'simulated' only because no social backend is connected
(META token is a placeholder + expired; flags off) — the code can post for real.

- backend: pipeline statuses (drafted→pending-approval→approved→posted), new
  /submit (Submit for approval) + /approve (Steve sign-off) + /status (live vs
  simulated truth) endpoints. /publish already does real Meta/Ayrshare posting +
  marks posted; instant = inline approve+confirm.
- panel: board columns = the 4 pipeline stages; per-card action by status
  (Submit for approval → Sign off ✓ → Publish); ⚡ Instant post button on the form;
  banner now reads ● LIVE (backend connected) or ● SIMULATED from /status, with
  a real ⚠️ 'posts to the REAL account' confirm when live.

Verified end-to-end on an isolated port: draft→submit→approve→publish stages
correctly (no backend), instant path works. Goes LIVE the moment a valid Meta
token + META_IG_USER_ID land (the only remaining blocker).

Files touched

Diff

commit 88a51a704fad2b25d7ea6a5f1099a238366f7508
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 22 09:57:11 2026 -0700

    Social: real approval pipeline + instant posting + truthful live/simulated UX
    
    Audit found posts were 'simulated' only because no social backend is connected
    (META token is a placeholder + expired; flags off) — the code can post for real.
    
    - backend: pipeline statuses (drafted→pending-approval→approved→posted), new
      /submit (Submit for approval) + /approve (Steve sign-off) + /status (live vs
      simulated truth) endpoints. /publish already does real Meta/Ayrshare posting +
      marks posted; instant = inline approve+confirm.
    - panel: board columns = the 4 pipeline stages; per-card action by status
      (Submit for approval → Sign off ✓ → Publish); ⚡ Instant post button on the form;
      banner now reads ● LIVE (backend connected) or ● SIMULATED from /status, with
      a real ⚠️ 'posts to the REAL account' confirm when live.
    
    Verified end-to-end on an isolated port: draft→submit→approve→publish stages
    correctly (no backend), instant path works. Goes LIVE the moment a valid Meta
    token + META_IG_USER_ID land (the only remaining blocker).
---
 modules/social/index.js   |  45 ++++++++++++++-
 public/panels/social.html |  14 ++---
 public/panels/social.js   | 139 +++++++++++++++++++++++++++++++++++++++++-----
 3 files changed, 177 insertions(+), 21 deletions(-)

diff --git a/modules/social/index.js b/modules/social/index.js
index ccb0aa6..d4b2fa9 100644
--- a/modules/social/index.js
+++ b/modules/social/index.js
@@ -18,7 +18,9 @@ const QUEUE_FILE = path.join(DATA_DIR, 'social-queue.json');
 const PUBLOG_FILE = path.join(DATA_DIR, 'social-publish-log.json');
 
 const CHANNELS = ['instagram', 'tiktok', 'pinterest', 'facebook'];
-const STATUSES = ['idea', 'drafted', 'scheduled', 'posted'];
+// Approval pipeline: Save draft → Submit for approval → Steve sign-off → Publish.
+// 'scheduled' kept for back-compat with the time-based scheduler tick.
+const STATUSES = ['idea', 'drafted', 'pending-approval', 'approved', 'scheduled', 'posted'];
 
 // Ayrshare uses the same lowercase platform names we already use as CHANNELS.
 const AYRSHARE_API = 'https://api.ayrshare.com/api/post';
@@ -297,6 +299,47 @@ module.exports = {
       res.json({ ok: true, entry, posts: list });
     });
 
+    // GET /status — is live posting actually possible? Drives the UI banner so it
+    // tells the truth (real vs simulated) instead of being hard-coded "dry-run".
+    router.get('/status', (req, res) => {
+      const backend = backendName();
+      res.json({
+        live: !!backend,                                   // a real backend is configured
+        backend,                                           // 'ayrshare' | 'meta' | null
+        schedulerLive: process.env.SOCIAL_SCHEDULER_LIVE === 'true',
+        reachable: backend ? backendChannels() : [],       // channels the backend can post to
+        note: backend
+          ? 'Live posting is configured — approved + confirmed posts go to real accounts.'
+          : 'No social backend connected — posts stage only (simulated). Set META_ACCESS_TOKEN + META_IG_USER_ID (or AYRSHARE_API_KEY) to go live.',
+      });
+    });
+
+    // POST /submit — move a draft into the approval queue (Submit for approval).
+    router.post('/submit', (req, res) => {
+      const list = loadQueue();
+      const p = list.find(x => x.id === String((req.body || {}).id));
+      if (!p) return res.status(404).json({ ok: false, error: 'post not found' });
+      p.status = 'pending-approval';
+      p.approved = false;                  // re-submitting always re-opens approval
+      p.submittedAt = new Date().toISOString();
+      p.updatedAt = p.submittedAt;
+      saveQueue(list);
+      res.json({ ok: true, entry: p, posts: list });
+    });
+
+    // POST /approve — Steve's sign-off. Clears the post for live publishing.
+    router.post('/approve', (req, res) => {
+      const list = loadQueue();
+      const p = list.find(x => x.id === String((req.body || {}).id));
+      if (!p) return res.status(404).json({ ok: false, error: 'post not found' });
+      p.approved = true;
+      p.status = 'approved';
+      p.approvedAt = new Date().toISOString();
+      p.updatedAt = p.approvedAt;
+      saveQueue(list);
+      res.json({ ok: true, entry: p, posts: list });
+    });
+
     // POST /publish — GATED. Would post to a live social account, so:
     //   • require body.confirm === true
     //   • default to dry-run (dryRun !== false → just validate + echo)
diff --git a/public/panels/social.html b/public/panels/social.html
index e9b8fb1..a95ef28 100644
--- a/public/panels/social.html
+++ b/public/panels/social.html
@@ -1,7 +1,5 @@
 <div class="muted-banner">
-  Queue and stage social posts for the trade. Group by status, draft captions, and rehearse a
-  publish — but nothing here posts to a live account. “Publish now” is a gated dry-run that
-  stages only; connect a social API to go live.
+  Checking posting status…
 </div>
 
 <div class="soc-wrap">
@@ -30,10 +28,10 @@
           <div style="width:150px">
             <label>Status</label>
             <select name="status">
-              <option value="idea">Idea</option>
               <option value="drafted">Drafted</option>
-              <option value="scheduled">Scheduled</option>
-              <option value="posted">Posted</option>
+              <option value="idea">Idea</option>
+              <option value="pending-approval">Pending approval</option>
+              <option value="approved">Approved</option>
             </select>
           </div>
         </div>
@@ -55,11 +53,13 @@
           <label>Notes</label>
           <input name="notes" placeholder="Angle, product line, asset to shoot…">
         </div>
-        <div class="row" style="margin-top:12px;align-items:center">
+        <div class="row" style="margin-top:12px;align-items:center;gap:8px">
           <button type="submit" class="btn gold" id="soc-save">Add to queue</button>
+          <button type="button" class="btn gated" id="soc-instant" title="Save + approve + publish in one gated step">⚡ Instant post…</button>
           <button type="button" class="btn ghost" id="soc-reset">Clear</button>
           <span class="muted" id="soc-editing" style="font-size:12px;margin-left:auto"></span>
         </div>
+        <div class="sc-result" id="soc-instant-result" hidden></div>
       </form>
     </div>
 
diff --git a/public/panels/social.js b/public/panels/social.js
index 56cf130..f34c3be 100644
--- a/public/panels/social.js
+++ b/public/panels/social.js
@@ -9,10 +9,12 @@ window.MCC_PANELS['social'] = {
 
     const CHAN_ICON = { instagram: '📷', tiktok: '🎵', pinterest: '📌', facebook: '👍' };
     const CHAN_NAME = { instagram: 'Instagram', tiktok: 'TikTok', pinterest: 'Pinterest', facebook: 'Facebook' };
+    // Approval pipeline: Save draft → Submit for approval → Steve sign-off → Publish.
     const COLUMNS = [
       { status: 'idea', title: 'Ideas' },
       { status: 'drafted', title: 'Drafted' },
-      { status: 'scheduled', title: 'Scheduled' },
+      { status: 'pending-approval', title: 'Pending approval' },
+      { status: 'approved', title: 'Approved · ready to publish' },
       { status: 'posted', title: 'Posted' },
     ];
 
@@ -21,7 +23,9 @@ window.MCC_PANELS['social'] = {
     const form = $('#soc-form');
     const cardTpl = $('#soc-card-tpl');
 
-    const state = { posts: [], editingId: null };
+    // Whether a real social backend is connected (so the UI tells the truth about
+    // live vs simulated instead of always saying "dry-run"). Filled by loadStatus().
+    const state = { posts: [], editingId: null, live: false, backend: null, statusNote: '' };
 
     async function load() {
       try {
@@ -31,6 +35,22 @@ window.MCC_PANELS['social'] = {
       renderBoard();
     }
 
+    // Pull live/simulated state and update the banner so it's accurate.
+    async function loadStatus() {
+      try {
+        const s = await fetch('/api/social/status').then(r => r.json());
+        state.live = !!s.live; state.backend = s.backend || null; state.statusNote = s.note || '';
+      } catch (_) { state.live = false; }
+      const banner = root.querySelector('.muted-banner');
+      if (banner) {
+        banner.innerHTML = state.live
+          ? `<b>● LIVE</b> — backend <b>${esc(state.backend)}</b> connected. Approved + confirmed posts go to <b>real accounts</b>. Pipeline: Save draft → Submit for approval → Steve sign-off ✓ → Publish.`
+          : `<b>● SIMULATED</b> — no social backend connected, so a publish <b>stages only</b> (nothing is sent). ${esc(state.statusNote)} Pipeline still works end-to-end: Save draft → Submit for approval → Steve sign-off ✓ → Publish.`;
+        banner.style.borderLeft = state.live ? '3px solid #3a8a4a' : '3px solid #c79a3a';
+        banner.style.paddingLeft = '10px';
+      }
+    }
+
     function whenLabel(p) {
       if (!p.date && !p.time) return 'no date';
       let out = '';
@@ -79,17 +99,49 @@ window.MCC_PANELS['social'] = {
         tags.appendChild(s);
       });
 
+      // Status-driven pipeline action on each card:
+      //   drafted → Submit for approval · pending-approval → Sign off ✓ (Steve) · approved → Publish
       const pub = node.querySelector('.sc-pub');
-      // Publish only makes sense for scheduled posts; hide elsewhere.
-      if (p.status !== 'scheduled') pub.remove();
-      else pub.addEventListener('click', () => publishFlow(p, node));
+      if (p.status === 'drafted') {
+        pub.className = 'btn ghost sc-pub';
+        pub.textContent = 'Submit for approval →';
+        pub.title = 'Move into the approval queue';
+        pub.addEventListener('click', () => transition(p, 'submit'));
+      } else if (p.status === 'pending-approval') {
+        pub.className = 'btn gold sc-pub';
+        pub.textContent = 'Sign off ✓';
+        pub.title = "Steve's approval — clears this post for live publishing";
+        pub.addEventListener('click', () => transition(p, 'approve'));
+      } else if (p.status === 'approved' || p.status === 'scheduled') {
+        pub.className = 'btn gated sc-pub';
+        pub.textContent = state.live ? 'Publish (live)…' : 'Publish (staged)…';
+        pub.title = state.live ? 'Gated — posts to the real account' : 'Gated — stages only (no backend connected)';
+        pub.addEventListener('click', () => publishFlow(p, node));
+      } else {
+        pub.remove(); // idea / posted → no pipeline action
+      }
 
       node.querySelector('.sc-edit').addEventListener('click', () => startEdit(p));
       node.querySelector('.sc-del').addEventListener('click', () => removePost(p.id));
       return node;
     }
 
-    // ── gated publish: dry-run → confirm → stage ─────────────────────────────
+    // ── pipeline transitions: Submit for approval / Steve sign-off ───────────
+    async function transition(p, action) {
+      if (action === 'approve' && !confirm(
+        `Sign off on this ${CHAN_NAME[p.channel] || p.channel} post?\n\n` +
+        `This is Steve's approval — it clears the post for live publishing.`)) return;
+      try {
+        await fetch(`/api/social/${action}`, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ id: p.id }),
+        });
+      } catch (_) {}
+      await load();
+    }
+
+    // ── gated publish: dry-run → confirm → real publish (or stage if no backend)
     async function publishFlow(p, node) {
       const box = node.querySelector('.sc-result');
       box.hidden = false;
@@ -113,15 +165,16 @@ window.MCC_PANELS['social'] = {
       }
 
       const w = dry.would || {};
-      const summary = `Would post to ${CHAN_NAME[w.channel] || w.channel} at ${w.when}.`;
-      if (!confirm(
-        `${summary}\n\nThis is a GATED action. No real social API is connected, so it will be ` +
-        `STAGED only — nothing goes to a live account.\n\nStage this publish?`)) {
-        box.textContent = 'Dry run only — staging cancelled. Nothing was sent.';
+      const summary = `Post to ${CHAN_NAME[w.channel] || w.channel} at ${w.when}.`;
+      const prompt = state.live
+        ? `${summary}\n\n⚠️ LIVE — this posts to the REAL ${CHAN_NAME[w.channel] || w.channel} account right now. This cannot be undone from here.\n\nPublish for real?`
+        : `${summary}\n\nThis is a GATED action. No social backend is connected, so it will be STAGED only — nothing goes to a live account.\n\nStage this publish?`;
+      if (!confirm(prompt)) {
+        box.textContent = state.live ? 'Publish cancelled. Nothing was sent.' : 'Dry run only — staging cancelled. Nothing was sent.';
         return;
       }
 
-      box.textContent = 'Staging…';
+      box.textContent = state.live ? 'Publishing…' : 'Staging…';
       let res;
       try {
         res = await fetch('/api/social/publish', {
@@ -135,7 +188,13 @@ window.MCC_PANELS['social'] = {
       }
       if (res && res.staged) {
         box.classList.add('staged');
-        box.textContent = res.message || 'Staged — connect a social API to enable live posting.';
+        box.textContent = res.message || 'Staged — connect a social backend to enable live posting.';
+      } else if (res && res.live && res.ok) {
+        box.textContent = '✓ Posted live to the real account.';
+        await load(); // moves the card into Posted
+      } else if (res && res.live && !res.ok) {
+        const err = res.result && (res.result.error || (res.result.message)) || res.error;
+        box.textContent = 'Live publish failed: ' + (typeof err === 'string' ? err : JSON.stringify(err || 'unknown'));
       } else {
         box.textContent = (res && (res.message || res.error)) || 'Unexpected response.';
       }
@@ -207,6 +266,59 @@ window.MCC_PANELS['social'] = {
 
     $('#soc-reset').addEventListener('click', resetForm);
 
+    // ── Instant post: Save + auto-approve + publish in one gated step ─────────
+    $('#soc-instant').addEventListener('click', async () => {
+      const fd = new FormData(form);
+      const channel = fd.get('channel');
+      const caption = (fd.get('caption') || '').toString().trim();
+      const box = $('#soc-instant-result');
+      box.hidden = false; box.classList.remove('staged');
+      if (!caption) { box.textContent = 'Add a caption first.'; return; }
+
+      // 1) create/update the post, pre-approved (instant skips the queue stages).
+      box.textContent = 'Saving…';
+      let entry;
+      try {
+        const r = await fetch('/api/social/posts', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({
+            id: state.editingId || undefined, channel,
+            date: (fd.get('date') || '').toString(), time: (fd.get('time') || '').toString(),
+            caption, hashtags: (fd.get('hashtags') || '').toString(),
+            imageUrl: (fd.get('imageUrl') || '').toString().trim(),
+            notes: (fd.get('notes') || '').toString().trim(),
+            status: 'approved', approved: true,
+          }),
+        }).then(r => r.json());
+        entry = r && r.entry;
+      } catch (_) {}
+      if (!entry) { box.textContent = 'Could not save the post.'; return; }
+
+      // 2) gated confirm — truthful about live vs staged.
+      const where = CHAN_NAME[channel] || channel;
+      const prompt = state.live
+        ? `⚡ INSTANT LIVE POST\n\nThis posts to the REAL ${where} account right now. This cannot be undone.\n\nPost now?`
+        : `⚡ Instant post\n\nNo backend connected, so this STAGES only — nothing is sent to ${where}.\n\nStage it?`;
+      if (!confirm(prompt)) { box.textContent = 'Cancelled — saved to the board as Approved.'; resetForm(); await load(); return; }
+
+      // 3) publish (confirm + approve inline).
+      box.textContent = state.live ? 'Publishing…' : 'Staging…';
+      let res;
+      try {
+        res = await fetch('/api/social/publish', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ id: entry.id, dryRun: false, confirm: true, approve: true }),
+        }).then(r => r.json());
+      } catch (_) { box.textContent = 'Publish request failed.'; return; }
+
+      if (res && res.staged) { box.classList.add('staged'); box.textContent = res.message || 'Staged — no backend connected.'; }
+      else if (res && res.live && res.ok) box.textContent = '✓ Posted live to the real account.';
+      else if (res && res.live && !res.ok) { const e = res.result && (res.result.error || res.result.message) || res.error; box.textContent = 'Live publish failed: ' + (typeof e === 'string' ? e : JSON.stringify(e || 'unknown')); }
+      else box.textContent = (res && (res.message || res.error)) || 'Unexpected response.';
+      resetForm();
+      await load();
+    });
+
     // ── best times helper ────────────────────────────────────────────────────
     async function loadBestTimes() {
       const box = $('#soc-besttimes');
@@ -232,6 +344,7 @@ window.MCC_PANELS['social'] = {
       }).join('');
     }
 
+    loadStatus();
     load();
     loadBestTimes();
   },

← fe2afd8 meta-token-health: fix dead George alert URL (HTTPS flip 202  ·  back to Marketing Command Center  ·  CC module: proxy Connie cc-agent when no local CTCT token (D eac09ca →