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