← back to Marketing Command Center

modules/engine/index.js

187 lines

// Engine — daily AI-suggested social posts with an approve → schedule → post flow.
// This module owns the SUGGESTION QUEUE (data/engine-queue.json via store.js). Each
// day the generator (agent 2 — ./generate) proposes a few product posts per channel;
// Steve approves/skips/edits them here; the scheduler (agent 3 — ./scheduler) fires
// approved items at their scheduled time through channels.postLive (the same rails
// as the /publish route). This foundation module is safe with ./generate and
// ./scheduler ABSENT — both are lazy-required in try/catch so a missing file never
// crashes the mount.
const store = require('./store');
const config = require('./config');
const { clean } = require('../../lib/leak-deny');

// ── local-date helper ────────────────────────────────────────────────────────
// The queue keys suggestions on the local calendar day. Format YYYY-MM-DD in the
// server's local timezone (NOT UTC — toISOString would drift across midnight).
function today() {
  const d = new Date();
  const p = n => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}

// Lazily load the generator (agent 2 builds ./generate). Absence is fine.
function loadGenerator() {
  let gen;
  try { gen = require('./generate'); } catch { gen = null; }
  return gen;
}

// Build a full ISO string (with local offset) for `date` at `HH:MM` local time.
// If that time has already passed today, roll to the same slot tomorrow.
function slotToISO(date, hhmm, { rollIfPast = false } = {}) {
  const [h, m] = String(hhmm || '00:00').split(':').map(Number);
  const d = new Date(`${date}T00:00:00`);
  d.setHours(h || 0, m || 0, 0, 0);
  if (rollIfPast && d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
  return d.toISOString();
}

module.exports = {
  id: 'engine',
  title: 'Engine',
  icon: '⚙️',

  mount(router) {
    // Kick the scheduler if agent 3 has built it. Absence must NOT crash mount.
    try {
      const scheduler = require('./scheduler');
      if (scheduler && typeof scheduler.start === 'function') scheduler.start();
    } catch { /* scheduler not built yet — fine */ }

    // GET /status — engine + queue health for today.
    router.get('/status', (_req, res) => {
      const items = store.load();
      const d = today();
      const todays = items.filter(it => it.date === d);
      const counts = { suggested: 0, approved: 0, posting: 0, posted: 0, failed: 0, skipped: 0, gated: 0 };
      for (const it of todays) if (counts[it.status] != null) counts[it.status]++;
      // Pull the real last-tick timestamp from the scheduler if it's built.
      let lastTick = null;
      try {
        const scheduler = require('./scheduler');
        if (scheduler && typeof scheduler.lastTick === 'function') lastTick = scheduler.lastTick();
      } catch { /* scheduler not built yet — null */ }
      res.json({
        scheduler: process.env.ENGINE_SCHEDULER !== 'off',
        lastTick,
        counts,
        total: items.length,
      });
    });

    // GET /today — today's suggestions. If none exist and generate isn't disabled
    // (?generate=0), ask the generator to build them. Missing generator → empty.
    router.get('/today', async (req, res) => {
      const d = today();
      let items = store.load().filter(it => it.date === d);
      if (!items.length && req.query.generate !== '0') {
        const gen = loadGenerator();
        if (!gen) return res.json({ items: [], note: 'generator not built yet' });
        try {
          await gen.generateToday();
          items = store.load().filter(it => it.date === d);
        } catch (e) {
          return res.status(500).json({ items: [], error: e.message });
        }
      }
      res.json({ items });
    });

    // POST /generate — force a generation run now (same lazy require).
    router.post('/generate', async (_req, res) => {
      const gen = loadGenerator();
      if (!gen) return res.json({ items: [], note: 'generator not built yet' });
      try {
        await gen.generateToday();
        const items = store.load().filter(it => it.date === today());
        res.json({ items });
      } catch (e) {
        res.status(500).json({ error: e.message });
      }
    });

    // POST /approve { id, scheduledAt?, privacy? } — suggested → approved.
    // Defaults scheduledAt to today at the item's suggestedSlot (rolling to
    // tomorrow's same slot if that time already passed). YouTube defaults to
    // privacy 'public' unless the caller specifies one.
    router.post('/approve', (req, res) => {
      const { id, scheduledAt, privacy } = req.body || {};
      if (!id) return res.status(400).json({ ok: false, error: 'id required' });
      const it = store.get(id);
      if (!it) return res.status(404).json({ ok: false, error: 'item not found' });

      const patch = {};
      patch.scheduledAt = scheduledAt || slotToISO(today(), it.suggestedSlot, { rollIfPast: true });
      // YouTube defaults PRIVATE on approve — an unread LLM-written title going
      // public is the one irreversible mistake in this system. Promote to public
      // deliberately via /api/channels/youtube/visibility after review.
      if (it.channel === 'youtube') patch.privacy = privacy || 'private';
      else if (privacy != null) patch.privacy = privacy;

      const updated = store.transition(id, 'suggested', 'approved', patch);
      if (!updated) return res.status(409).json({ ok: false, error: `cannot approve — item is not 'suggested'` });
      res.json({ ok: true, item: updated });
    });

    // POST /skip { id } — suggested → skipped.
    router.post('/skip', (req, res) => {
      const { id } = req.body || {};
      if (!id) return res.status(400).json({ ok: false, error: 'id required' });
      const updated = store.transition(id, 'suggested', 'skipped', {});
      if (!updated) return res.status(409).json({ ok: false, error: `cannot skip — item is not 'suggested'` });
      res.json({ ok: true, item: updated });
    });

    // POST /arm { id } — gated → suggested. For when TikTok/Threads App Review lands
    // and a held item can re-enter the normal approve flow.
    router.post('/arm', (req, res) => {
      const { id } = req.body || {};
      if (!id) return res.status(400).json({ ok: false, error: 'id required' });
      const updated = store.transition(id, 'gated', 'suggested', { gateReason: null });
      if (!updated) return res.status(409).json({ ok: false, error: `cannot arm — item is not 'gated'` });
      res.json({ ok: true, item: updated });
    });

    // POST /edit { id, caption?, hashtags?, mediaUrl?, scheduledAt? } — only while
    // suggested|approved. Re-runs the leak clean() on any edited caption.
    router.post('/edit', (req, res) => {
      const { id, caption, hashtags, mediaUrl, scheduledAt } = req.body || {};
      if (!id) return res.status(400).json({ ok: false, error: 'id required' });
      const it = store.get(id);
      if (!it) return res.status(404).json({ ok: false, error: 'item not found' });
      if (it.status !== 'suggested' && it.status !== 'approved') {
        return res.status(409).json({ ok: false, error: `cannot edit — status is '${it.status}'` });
      }
      const patch = {};
      if (caption != null) patch.caption = clean(caption);
      if (hashtags != null) patch.hashtags = Array.isArray(hashtags) ? hashtags : it.hashtags;
      if (mediaUrl != null) patch.mediaUrl = mediaUrl;
      if (scheduledAt != null) patch.scheduledAt = scheduledAt;
      // Same-status CAS (not upsert): if the scheduler moved this item to
      // posting/posted between our read and this write, the transition returns
      // null instead of stomping the live status back — which would re-arm a
      // double post. Edit never changes status; it only survives if status held.
      const updated = store.transition(id, it.status, it.status, patch);
      if (!updated) return res.status(409).json({ ok: false, error: 'item changed state while editing — refresh and retry' });
      res.json({ ok: true, item: updated });
    });

    // GET /queue?status=&days=7 — filtered history, newest first.
    router.get('/queue', (req, res) => {
      const { status } = req.query;
      const days = Math.max(1, parseInt(req.query.days, 10) || 7);
      const cutoff = new Date();
      cutoff.setDate(cutoff.getDate() - days);
      const cutoffDay = cutoff.toISOString().slice(0, 10);
      let items = store.load().filter(it => (it.date || '') >= cutoffDay);
      if (status) items = items.filter(it => it.status === status);
      items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
      res.json({ items });
    });

    // GET /config — effective config. PUT /config — merge + persist a patch.
    router.get('/config', (_req, res) => res.json(config.getConfig()));
    router.put('/config', (req, res) => res.json(config.setConfig(req.body || {})));
  },
};