← 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
M modules/social/index.jsM public/panels/social.htmlM public/panels/social.js
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 →