← back to Marketing Command Center
modules/social/index.js
435 lines
// 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).
//
// 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'];
// 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';
// ── live-posting backend selection ───────────────────────────────────────────
// Two ways to actually post:
// • ayrshare — one paid key ($149/mo) → IG/TikTok/Pinterest/FB.
// • meta — FREE native Graph API → Instagram + Facebook only.
// Ayrshare wins if both are configured (it covers more channels).
const META_GRAPH_VERSION = () => process.env.META_GRAPH_VERSION || 'v21.0';
const metaConfigured = () =>
!!process.env.META_ACCESS_TOKEN &&
(!!process.env.META_IG_USER_ID || !!process.env.META_FB_PAGE_ID);
function backendName() {
if (process.env.AYRSHARE_API_KEY) return 'ayrshare';
if (metaConfigured()) return 'meta';
return null;
}
// Channels the active backend can actually reach (Meta = IG+FB only).
function backendChannels() {
const b = backendName();
if (b === 'ayrshare') return CHANNELS;
if (b === 'meta') {
const c = [];
if (process.env.META_IG_USER_ID) c.push('instagram');
if (process.env.META_FB_PAGE_ID) c.push('facebook');
return c;
}
return [];
}
const liveEnabled = () => backendName() !== null;
// ── tiny JSON persistence helpers ────────────────────────────────────────────
function readJson(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch { return fallback; }
}
function writeJson(file, val) {
try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
fs.writeFileSync(file, JSON.stringify(val, null, 2));
}
function loadQueue() {
const v = readJson(QUEUE_FILE, null);
return Array.isArray(v) ? v : [];
}
function saveQueue(list) { writeJson(QUEUE_FILE, list); }
// ── field hygiene ────────────────────────────────────────────────────────────
const cap = (s, n) => String(s == null ? '' : s).slice(0, n);
const genId = () => 'post_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// hashtags accepted as array or "#a #b" / "a, b" string → normalized array of #tags
function normHashtags(input) {
let arr = [];
if (Array.isArray(input)) arr = input;
else if (typeof input === 'string') arr = input.split(/[\s,]+/);
return arr
.map(t => String(t).trim().replace(/^#+/, ''))
.filter(Boolean)
.map(t => '#' + t.replace(/[^A-Za-z0-9_]/g, '').slice(0, 50))
.filter(t => t.length > 1)
.slice(0, 30);
}
// Curated optimal posting windows for a trade / luxury-design audience (local time).
// Static guidance — no analytics call, no creds. Times reflect when interior
// designers, architects & affluent homeowners tend to be browsing.
const BEST_TIMES = {
instagram: {
label: 'Instagram',
windows: ['Tue–Thu 11:00–13:00', 'Wed 19:00–21:00', 'Sun 10:00–12:00'],
note: 'Editorial carousels and Reels of grasscloth/silk detail land best mid-morning and early evening, when designers pin inspiration.',
},
tiktok: {
label: 'TikTok',
windows: ['Tue 18:00–20:00', 'Thu 12:00–14:00', 'Sat 09:00–11:00'],
note: 'Install time-lapses and texture close-ups perform in the evening scroll and the weekend project-planning window.',
},
pinterest: {
label: 'Pinterest',
windows: ['Sat 20:00–23:00', 'Sun 14:00–17:00', 'Fri 15:00–18:00'],
note: 'Pinterest skews to weekend dreaming — room mood-boards and palette pins compound for months, so evergreen wins.',
},
facebook: {
label: 'Facebook',
windows: ['Wed 09:00–11:00', 'Thu 13:00–15:00', 'Fri 10:00–12:00'],
note: 'Reaches an older homeowner and hospitality-spec audience; midweek daytime drives trade-consultation enquiries.',
},
};
// ── 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();
});
}
// ── Meta Graph publisher (FREE — Instagram + Facebook only) ──────────────────
// Uses the official Graph API. Requires a long-lived Page access token plus the
// IG Business account id and/or the FB Page id. Posts nothing for tiktok/pinterest
// (returns a clear per-channel error — those need Ayrshare). NEVER throws.
function graphPost(pathPart, params) {
return new Promise((resolve) => {
const body = new URLSearchParams(params).toString();
const req = https.request(`https://graph.facebook.com/${META_GRAPH_VERSION()}/${pathPart}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', '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) }; }
resolve({ status: r.statusCode, json });
}); });
req.on('error', e => resolve({ status: 0, json: { error: { message: String(e).slice(0, 200) } } }));
req.write(body); req.end();
});
}
async function metaPost({ platforms, caption, hashtags, imageUrl }) {
const token = process.env.META_ACCESS_TOKEN;
const igUser = process.env.META_IG_USER_ID;
const fbPage = process.env.META_FB_PAGE_ID;
const text = [caption, (hashtags || []).join(' ')].filter(Boolean).join('\n\n').trim();
const results = {};
for (const pl of platforms) {
if (pl === 'instagram') {
if (!igUser) { results.instagram = { ok: false, error: 'META_IG_USER_ID not set' }; continue; }
if (!imageUrl) { results.instagram = { ok: false, error: 'Instagram requires a public imageUrl (Graph IG publishing is media-only)' }; continue; }
// Two-step: create media container, then publish it.
const c = await graphPost(`${igUser}/media`, { image_url: imageUrl, caption: text, access_token: token });
if (!c.json.id) { results.instagram = { ok: false, status: c.status, error: (c.json.error && c.json.error.message) || 'container create failed' }; continue; }
const pub = await graphPost(`${igUser}/media_publish`, { creation_id: c.json.id, access_token: token });
results.instagram = pub.json.id
? { ok: true, id: pub.json.id }
: { ok: false, status: pub.status, error: (pub.json.error && pub.json.error.message) || 'media_publish failed' };
} else if (pl === 'facebook') {
if (!fbPage) { results.facebook = { ok: false, error: 'META_FB_PAGE_ID not set' }; continue; }
const r = imageUrl
? await graphPost(`${fbPage}/photos`, { url: imageUrl, caption: text, access_token: token })
: await graphPost(`${fbPage}/feed`, { message: text, access_token: token });
const id = r.json.id || r.json.post_id;
results.facebook = id
? { ok: true, id }
: { ok: false, status: r.status, error: (r.json.error && r.json.error.message) || 'page post failed' };
} else {
results[pl] = { ok: false, error: `Meta backend supports only instagram + facebook — use Ayrshare for ${pl}` };
}
}
const vals = Object.values(results);
const ok = vals.length > 0 && vals.every(r => r.ok);
return { ok, live: true, backend: 'meta', response: results };
}
// ── publish dispatcher ───────────────────────────────────────────────────────
// Routes a publish to the active backend. No backend configured → mock (sends
// nothing). The /publish route + scheduler call THIS, not a specific backend.
function publish(opts) {
const b = backendName();
if (b === 'ayrshare') return ayrsharePost(opts);
if (b === 'meta') return metaPost(opts);
return Promise.resolve({ ok: true, live: false, mock: true, wouldSend: opts });
}
// ── 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 backend — not sent' }); continue; }
const r = await publish({ platforms: [p.channel], caption: p.caption, hashtags: p.hashtags, imageUrl: p.imageUrl });
appendLog({ kind: 'scheduler-publish', id: p.id, channel: p.channel, backend: backendName(), 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',
title: 'Social Scheduler',
icon: '📲',
mount(router) {
startScheduler();
// GET /posts?status=&channel= — list the queue, newest schedule first
router.get('/posts', (req, res) => {
let list = loadQueue();
const { status, channel } = req.query;
if (status && STATUSES.includes(status)) list = list.filter(p => p.status === status);
if (channel && CHANNELS.includes(channel)) list = list.filter(p => p.channel === channel);
list.sort((a, b) =>
(a.date || '').localeCompare(b.date || '') ||
(a.time || '').localeCompare(b.time || ''));
res.json({ posts: list });
});
// POST /posts — add/update one post (match on id); ?delete=id removes one
router.post('/posts', (req, res) => {
const list = loadQueue();
if (req.query.delete) {
const id = String(req.query.delete);
const next = list.filter(p => p.id !== id);
saveQueue(next);
return res.json({ ok: true, deleted: id, posts: next });
}
const b = req.body || {};
if (!b.channel || !CHANNELS.includes(b.channel)) {
return res.status(400).json({ ok: false, error: `channel must be one of ${CHANNELS.join(', ')}` });
}
if (b.status && !STATUSES.includes(b.status)) {
return res.status(400).json({ ok: false, error: `status must be one of ${STATUSES.join(', ')}` });
}
const entry = {
id: b.id && list.some(p => p.id === b.id) ? String(b.id) : genId(),
channel: b.channel,
date: cap(b.date, 10), // YYYY-MM-DD
time: cap(b.time, 5), // HH:MM
caption: cap(b.caption, 2200), // IG caption ceiling
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(),
};
const i = list.findIndex(p => p.id === entry.id);
if (i >= 0) list[i] = { ...list[i], ...entry };
else { entry.createdAt = entry.updatedAt; list.push(entry); }
saveQueue(list);
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)
// • there are NO real social API creds → ALWAYS stage, NEVER actually post.
router.post('/publish', (req, res) => {
const b = req.body || {};
const dryRun = b.dryRun !== false; // default true
const list = loadQueue();
const post = b.id ? list.find(p => p.id === String(b.id)) : null;
if (b.id && !post) {
return res.status(404).json({ ok: false, error: 'post not found' });
}
if (post && !CHANNELS.includes(post.channel)) {
return res.status(400).json({ ok: false, error: 'post has an invalid channel' });
}
const would = post && {
id: post.id,
channel: post.channel,
when: [post.date, post.time].filter(Boolean).join(' ') || 'unspecified',
caption: post.caption,
hashtags: post.hashtags,
imageUrl: post.imageUrl,
};
if (dryRun) {
return res.json({
ok: true,
dryRun: true,
staged: false,
would,
message: 'Dry run — nothing was sent. Set dryRun:false and confirm:true to stage a live publish.',
});
}
if (b.confirm !== true) {
return res.status(400).json({
ok: false,
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.',
});
}
// Backend configured + confirmed + approved → real publish (Ayrshare or Meta).
(async () => {
const r = await publish({ platforms: [post.channel], caption: post.caption, hashtags: post.hashtags, imageUrl: post.imageUrl });
appendLog({ kind: 'publish-live', id: post.id, channel: post.channel, backend: backendName(), 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? which backend? (no secret values leaked)
router.get('/connection', (req, res) => {
const backend = backendName();
res.json({
provider: backend || 'none',
backend, // 'ayrshare' | 'meta' | null
keyPresent: liveEnabled(),
schedulerLive: process.env.SOCIAL_SCHEDULER_LIVE === 'true',
mode: backend
? (process.env.SOCIAL_SCHEDULER_LIVE === 'true' ? 'LIVE' : 'manual-only (scheduler dry)')
: 'staging (no backend)',
channels: CHANNELS,
liveChannels: backendChannels(), // which channels the active backend can actually post to
});
});
// 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] })) });
});
},
};