← back to Marketing Command Center

modules/engine/store.js

83 lines

// Engine queue store — persistence for data/engine-queue.json (an array of items).
// Atomic writes (write tmp → fs.renameSync) so a crash mid-write never leaves a
// half-written / corrupt queue (mirrors server.js writeNotes()). load() returns []
// on missing/corrupt so a bad file never crashes the module.
//
// QUEUE ITEM SHAPE (later agents — candidates.js / generate.js / scheduler.js —
// MUST conform to this exactly):
// {
//   id: 'eng-<yyyymmdd>-<dwSkuOrShopifyId>-<channel>', // stable, dedupe key
//   date: 'YYYY-MM-DD',           // the suggestion's local calendar day
//   createdAt: ISO,
//   product: {
//     shopifyId, dwSku, handle, title, vendor, productType, imageUrl, productUrl
//   },
//   channel,                      // instagram|facebook|bluesky|youtube|tiktok|threads
//   caption,
//   hashtags: [],
//   mediaUrl,
//   videoUrl: null,
//   suggestedSlot: 'HH:MM',       // from config.slots[channel]
//   scheduledAt: null|ISO,        // set on approve (full ISO w/ offset)
//   privacy: null,                // youtube: 'public'|'private'|'unlisted'
//   status: 'suggested'|'approved'|'posting'|'posted'|'failed'|'skipped'|'gated',
//   gateReason: null,             // why a 'gated' item is held (TikTok/Threads review)
//   source: { llm, cost: 0 },     // provenance from the LLM that wrote the caption
//   attempts: 0,
//   lastError: null,
//   outboxId: null,               // channels-outbox id once posted
//   postedAt: null,
//   detail: null                  // adapter result on post
// }
const fs = require('fs');
const path = require('path');

const QUEUE_FILE = path.join(__dirname, '..', '..', 'data', 'engine-queue.json');

// load() — return the queue array, or [] on missing/corrupt (never throws).
function load() {
  try {
    const v = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf8'));
    return Array.isArray(v) ? v : [];
  } catch { return []; }
}

// save(items) — atomic write (tmp → rename) of the whole queue array.
function save(items) {
  try { fs.mkdirSync(path.dirname(QUEUE_FILE), { recursive: true }); } catch {}
  const tmp = QUEUE_FILE + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(Array.isArray(items) ? items : [], null, 2));
  fs.renameSync(tmp, QUEUE_FILE);
}

// get(id) — one item by id, or null.
function get(id) {
  return load().find(it => it.id === String(id)) || null;
}

// upsert(item) — add or replace the item with the same id, persist, return it.
function upsert(item) {
  const items = load();
  const i = items.findIndex(x => x.id === item.id);
  if (i >= 0) items[i] = { ...items[i], ...item };
  else items.push(item);
  save(items);
  return item;
}

// transition(id, fromStatus, toStatus, patch={}) — the compare-and-set that
// guards against double-posting. Re-reads the file FRESH, finds the item, asserts
// its current status === fromStatus (returns null if not — the guard), applies
// { status: toStatus, ...patch }, persists, and returns the updated item.
function transition(id, fromStatus, toStatus, patch = {}) {
  const items = load();
  const i = items.findIndex(x => x.id === String(id));
  if (i < 0) return null;
  if (items[i].status !== fromStatus) return null; // compare-and-set failed
  items[i] = { ...items[i], ...patch, status: toStatus };
  save(items);
  return items[i];
}

module.exports = { load, save, get, upsert, transition };