[object Object]

← back to Marketing Command Center

social: Ayrshare live-publish + scheduler scaffold (gated, off by default)

cfea2d7b87bb071d511a8b6f1691c7c66525944f · 2026-06-12 08:09:14 -0700 · SteveStudio2

- ayrsharePost(): one-key publisher to IG/TikTok/Pinterest/FB; mock/stage when no key.
- /publish live branch now posts via Ayrshare ONLY when AYRSHARE_API_KEY set AND
  confirm:true AND post approved:true; otherwise stages, sends nothing (unchanged).
- scheduler: 60s tick, fires due+approved 'scheduled' posts — DRY unless
  SOCIAL_SCHEDULER_LIVE=true + key present; marks posted, logs every action.
- new posts carry approved:false; /connection + /publish-log surfaces; .env.example.
- verified: no key => staged only, unapproved => rejected, nothing sent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit cfea2d7b87bb071d511a8b6f1691c7c66525944f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Fri Jun 12 08:09:14 2026 -0700

    social: Ayrshare live-publish + scheduler scaffold (gated, off by default)
    
    - ayrsharePost(): one-key publisher to IG/TikTok/Pinterest/FB; mock/stage when no key.
    - /publish live branch now posts via Ayrshare ONLY when AYRSHARE_API_KEY set AND
      confirm:true AND post approved:true; otherwise stages, sends nothing (unchanged).
    - scheduler: 60s tick, fires due+approved 'scheduled' posts — DRY unless
      SOCIAL_SCHEDULER_LIVE=true + key present; marks posted, logs every action.
    - new posts carry approved:false; /connection + /publish-log surfaces; .env.example.
    - verified: no key => staged only, unapproved => rejected, nothing sent.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .env.example            |   7 +++
 modules/social/index.js | 125 ++++++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 123 insertions(+), 9 deletions(-)

diff --git a/.env.example b/.env.example
index 8d60783..d237e8f 100644
--- a/.env.example
+++ b/.env.example
@@ -7,3 +7,10 @@ CTCT_ACCESS_TOKEN=
 CTCT_REFRESH_TOKEN=
 # LLM for suggested copy (optional; falls back to local templates if absent)
 ANTHROPIC_API_KEY=
+# Social live posting (Ayrshare — one key posts to IG/TikTok/Pinterest/FB).
+# Without it the Social module stages only and sends nothing. A post still
+# requires confirm:true + approved:true to ever go live.
+AYRSHARE_API_KEY=
+# Set to 'true' ONLY when you want the scheduler to auto-post due+approved posts.
+# Leave unset/false to keep the scheduler in dry mode (logs, never sends).
+SOCIAL_SCHEDULER_LIVE=
diff --git a/modules/social/index.js b/modules/social/index.js
index 1e34a00..10184df 100644
--- a/modules/social/index.js
+++ b/modules/social/index.js
@@ -1,16 +1,29 @@
 // Social Scheduler module — a post queue/board for Instagram, TikTok & friends,
 // plus curated best-times guidance for a luxury wallcoverings audience.
-// Self-contained per the MODULE CONTRACT (see README.md). Nothing here posts to a
-// live account: /publish is GATED, defaults to dry-run, and ALWAYS stages.
+// Self-contained per the MODULE CONTRACT (see README.md).
+//
+// LIVE PUBLISHING (added 2026-06-12) — fully gated, OFF by default:
+//   • Posting goes through Ayrshare (one API key → IG/TikTok/Pinterest/FB).
+//   • A post NEVER goes live unless ALL of these hold:
+//       1. process.env.AYRSHARE_API_KEY is set (no key → mock/stage only)
+//       2. the request/post carries confirm:true AND the post is approved:true
+//       3. for the scheduler, process.env.SOCIAL_SCHEDULER_LIVE === 'true'
+//   • With no key, behaviour is identical to before: dry-run + stage, nothing sent.
 const fs = require('fs');
 const path = require('path');
+const https = require('https');
 
 const DATA_DIR = path.join(__dirname, '..', '..', 'data');
 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'];
 
+// Ayrshare uses the same lowercase platform names we already use as CHANNELS.
+const AYRSHARE_API = 'https://api.ayrshare.com/api/post';
+const liveEnabled = () => !!process.env.AYRSHARE_API_KEY;
+
 // ── tiny JSON persistence helpers ────────────────────────────────────────────
 function readJson(file, fallback) {
   try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
@@ -69,6 +82,70 @@ const BEST_TIMES = {
   },
 };
 
+// ── publish log ──────────────────────────────────────────────────────────────
+function loadLog() { const v = readJson(PUBLOG_FILE, null); return Array.isArray(v) ? v : []; }
+function appendLog(entry) { const l = loadLog(); l.unshift({ ...entry, at: new Date().toISOString() }); writeJson(PUBLOG_FILE, l.slice(0, 500)); }
+
+// ── Ayrshare publisher ───────────────────────────────────────────────────────
+// Returns { ok, live, response|error }. NEVER throws. With no API key, returns
+// a mock result and sends nothing.
+function ayrsharePost({ platforms, caption, hashtags, imageUrl, scheduleDate }) {
+  return new Promise((resolve) => {
+    const key = process.env.AYRSHARE_API_KEY;
+    const post = [caption, (hashtags || []).join(' ')].filter(Boolean).join('\n\n').trim();
+    const payload = { post: post || ' ', platforms: platforms.filter(p => CHANNELS.includes(p)) };
+    if (imageUrl) payload.mediaUrls = [imageUrl];
+    if (scheduleDate) payload.scheduleDate = scheduleDate; // ISO; Ayrshare schedules server-side
+
+    if (!key) { // mock mode — no creds, send nothing
+      return resolve({ ok: true, live: false, mock: true, wouldSend: payload });
+    }
+    const body = JSON.stringify(payload);
+    const req = https.request(AYRSHARE_API, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}`, 'Content-Length': Buffer.byteLength(body) },
+    }, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => {
+      let json; try { json = JSON.parse(d); } catch { json = { raw: d.slice(0, 500) }; }
+      const ok = r.statusCode >= 200 && r.statusCode < 300 && json.status !== 'error';
+      resolve({ ok, live: true, status: r.statusCode, response: json });
+    }); });
+    req.on('error', e => resolve({ ok: false, live: true, error: String(e).slice(0, 200) }));
+    req.write(body); req.end();
+  });
+}
+
+// ── scheduler ────────────────────────────────────────────────────────────────
+// Ticks every 60s. Finds posts that are status:'scheduled', approved:true, and
+// due (date+time <= now). Behaviour:
+//   • SOCIAL_SCHEDULER_LIVE !== 'true'  → DRY: log "would publish", leave as-is.
+//   • live + key present                → publish via Ayrshare, mark 'posted'.
+// Idempotent: only ever touches due+approved posts; a posted post leaves the set.
+let schedulerTimer = null;
+function dueNow(p) {
+  if (p.status !== 'scheduled' || !p.approved || !p.date) return false;
+  const iso = `${p.date}T${(p.time || '00:00')}:00`;
+  const t = Date.parse(iso);
+  return Number.isFinite(t) && t <= Date.now();
+}
+async function schedulerTick() {
+  let list;
+  try { list = loadQueue(); } catch { return; }
+  const due = list.filter(dueNow);
+  if (!due.length) return;
+  const live = process.env.SOCIAL_SCHEDULER_LIVE === 'true' && liveEnabled();
+  for (const p of due) {
+    if (!live) { appendLog({ kind: 'scheduler-dry', id: p.id, channel: p.channel, note: 'due+approved; SOCIAL_SCHEDULER_LIVE off or no key — not sent' }); continue; }
+    const r = await ayrsharePost({ platforms: [p.channel], caption: p.caption, hashtags: p.hashtags, imageUrl: p.imageUrl });
+    appendLog({ kind: 'scheduler-publish', id: p.id, channel: p.channel, ok: r.ok, result: r.response || r.error });
+    if (r.ok) { const fresh = loadQueue(); const i = fresh.findIndex(x => x.id === p.id); if (i >= 0) { fresh[i].status = 'posted'; fresh[i].postedAt = new Date().toISOString(); saveQueue(fresh); } }
+  }
+}
+function startScheduler() {
+  if (schedulerTimer) return;
+  schedulerTimer = setInterval(() => { schedulerTick().catch(() => {}); }, 60 * 1000);
+  if (schedulerTimer.unref) schedulerTimer.unref();
+}
+
 // ── module ─────────────────────────────────────────────────────────────────────
 module.exports = {
   id: 'social',
@@ -76,6 +153,7 @@ module.exports = {
   icon: '📲',
 
   mount(router) {
+    startScheduler();
     // GET /posts?status=&channel= — list the queue, newest schedule first
     router.get('/posts', (req, res) => {
       let list = loadQueue();
@@ -116,6 +194,7 @@ module.exports = {
         hashtags: normHashtags(b.hashtags),
         imageUrl: cap(b.imageUrl, 600),
         status: STATUSES.includes(b.status) ? b.status : 'idea',
+        approved: b.approved === true,   // must be explicitly cleared before the scheduler will ever post it live
         notes: cap(b.notes, 1000),
         updatedAt: new Date().toISOString(),
       };
@@ -170,17 +249,45 @@ module.exports = {
           error: 'A live publish requires confirm:true.',
         });
       }
+      if (post && post.approved !== true && b.approve !== true) {
+        return res.status(400).json({
+          ok: false,
+          error: 'This post is not approved. Set approved:true on the post (or approve:true here) to clear it for live posting.',
+        });
+      }
+
+      // No Ayrshare key → behave exactly as before: stage, send nothing.
+      if (!liveEnabled()) {
+        appendLog({ kind: 'publish-stage', id: post && post.id, channel: post && post.channel, note: 'no AYRSHARE_API_KEY — staged only' });
+        return res.json({
+          ok: true, dryRun: false, staged: true, would,
+          message: 'Staged — set AYRSHARE_API_KEY to enable live posting. No post was sent to any live account.',
+        });
+      }
 
-      // confirmed + not dry run — but no creds exist anywhere. Always stage.
-      return res.json({
-        ok: true,
-        dryRun: false,
-        staged: true,
-        would,
-        message: 'Staged — connect a social API to enable live posting. No post was sent to any live account.',
+      // Key present + confirmed + approved → real publish via Ayrshare.
+      (async () => {
+        const r = await ayrsharePost({ platforms: [post.channel], caption: post.caption, hashtags: post.hashtags, imageUrl: post.imageUrl });
+        appendLog({ kind: 'publish-live', id: post.id, channel: post.channel, ok: r.ok, result: r.response || r.error });
+        if (r.ok) { const fresh = loadQueue(); const i = fresh.findIndex(x => x.id === post.id); if (i >= 0) { fresh[i].status = 'posted'; fresh[i].postedAt = new Date().toISOString(); saveQueue(fresh); } }
+        res.status(r.ok ? 200 : 502).json({ ok: r.ok, dryRun: false, live: true, staged: false, would, result: r.response || r.error });
+      })();
+    });
+
+    // GET /connection — is live posting wired? (no secret values leaked)
+    router.get('/connection', (req, res) => {
+      res.json({
+        provider: 'ayrshare',
+        keyPresent: liveEnabled(),
+        schedulerLive: process.env.SOCIAL_SCHEDULER_LIVE === 'true',
+        mode: liveEnabled() ? (process.env.SOCIAL_SCHEDULER_LIVE === 'true' ? 'LIVE' : 'manual-only (scheduler dry)') : 'staging (no key)',
+        channels: CHANNELS,
       });
     });
 
+    // GET /publish-log — recent publish/scheduler activity
+    router.get('/publish-log', (req, res) => res.json({ log: loadLog().slice(0, 100) }));
+
     // GET /best-times — curated optimal posting windows per channel
     router.get('/best-times', (req, res) => {
       res.json({ channels: CHANNELS.map(c => ({ channel: c, ...BEST_TIMES[c] })) });

← a905372 backsync: pull Yuri's 10 CRM features from prod (RFM, send-t  ·  back to Marketing Command Center  ·  gitignore transient social-publish-log.json 751cc47 →