← back to Marketing Command Center

modules/engine/generate.js

137 lines

// Engine generator — build today's suggestion queue. Idempotent: if today already
// has items it returns them unchanged. Otherwise it picks candidate products,
// writes per-channel captions, and upserts one queue item per product×channel.
// Never throws on empty candidates (pg down / no rows) — returns [].
const store = require('./store');
const config = require('./config');
const { pickCandidates } = require('./candidates');
const { captionsFor } = require('./llm');

const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads', 'linkedin'];

// local YYYY-MM-DD — must match modules/engine/index.js today() (local, not UTC).
function today() {
  const d = new Date();
  const p = n => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}

// compact yyyymmdd for the id prefix
function ymd(dateStr) {
  return String(dateStr).replace(/-/g, '');
}

// sanitize the sku/id segment of the id so it's a safe, stable token
function idSegment(product) {
  const raw = product.dwSku || product.shopifyId || 'unknown';
  return String(raw).replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
}

// build one queue item for a product×channel per the documented store.js shape
function buildItem(product, channel, caps, dateStr) {
  const cfg = config.getConfig();
  const GATED = config.GATED_CHANNELS;
  const slots = cfg.slots || {};
  const suggestedSlot = (slots[channel] && slots[channel][0]) || null;

  const ch = caps[channel] || {};
  let caption, hashtags = [], description = null;

  if (channel === 'youtube') {
    // youtube: title goes in caption, description in its own field
    caption = ch.title || '';
    description = ch.description || null;
  } else {
    caption = ch.caption || '';
    hashtags = Array.isArray(ch.hashtags) ? ch.hashtags : [];
  }

  return {
    id: `eng-${ymd(dateStr)}-${idSegment(product)}-${channel}`,
    date: dateStr,
    createdAt: new Date().toISOString(),
    product: {
      shopifyId: product.shopifyId || '',
      dwSku: product.dwSku || '',
      handle: product.handle || '',
      title: product.title || '',
      vendor: product.vendor || '',
      productType: product.productType || '',
      imageUrl: product.imageUrl || '',
      productUrl: product.productUrl || '',
    },
    channel,
    caption,
    hashtags,
    mediaUrl: product.imageUrl || null,
    videoUrl: null,
    suggestedSlot,
    scheduledAt: null,
    privacy: channel === 'youtube' ? 'public' : null,
    status: GATED[channel] ? 'gated' : 'suggested',
    gateReason: GATED[channel] || null,
    source: { llm: caps._source || 'template', cost: 0 },
    attempts: 0,
    lastError: null,
    outboxId: null,
    postedAt: null,
    detail: description !== null ? { description } : null,
  };
}

// generateToday() — idempotent daily generation. Returns today's items.
async function generateToday() {
  const dateStr = today();

  // idempotent: today already generated → return as-is. But if a NEW channel was
  // added since (e.g. linkedin), BACKFILL just the missing channels for today's
  // already-picked products — existing items (and their statuses) are untouched.
  const existing = store.load().filter(it => it.date === dateStr);
  if (existing.length) {
    const missing = CHANNELS.filter(ch => !existing.some(it => it.channel === ch));
    if (!missing.length) return existing;
    const seen = new Set(); const prods = [];
    for (const it of existing) {
      const key = it.product && (it.product.dwSku || it.product.shopifyId);
      if (key && !seen.has(key)) { seen.add(key); prods.push({ tags: [], ...it.product }); }
    }
    for (const product of prods) {
      let caps;
      try { caps = await captionsFor(product); } catch (e) { console.error('[engine backfill] captionsFor failed —', e.message); continue; }
      for (const channel of missing) {
        const item = buildItem(product, channel, caps, dateStr);
        if (!store.get(item.id)) store.upsert(item); // never touch existing items
      }
    }
    return store.load().filter(it => it.date === dateStr);
  }

  let products = [];
  try {
    products = await pickCandidates();
  } catch (e) {
    console.error('[engine generate] pickCandidates failed —', e.message);
    products = [];
  }
  if (!products.length) return []; // pg down / empty — no throw

  // captions are heavy (local LLM) — run sequentially, one product at a time
  for (const product of products) {
    let caps;
    try {
      caps = await captionsFor(product);
    } catch (e) {
      console.error('[engine generate] captionsFor failed —', e.message);
      continue; // llm is never-blocking, but be defensive
    }
    for (const channel of CHANNELS) {
      const item = buildItem(product, channel, caps, dateStr);
      store.upsert(item); // id-idempotent
    }
  }

  return store.load().filter(it => it.date === dateStr);
}

module.exports = { generateToday };