← 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] })) });
    });
  },
};