← 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 };