← 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
M .env.exampleM modules/social/index.js
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 →