← back to Marketing Command Center

modules/engine/config.js

89 lines

// Engine config — defaults + persisted overrides for the daily-suggestion engine.
// getConfig() returns DEFAULTS merged with data/engine-config.json; setConfig()
// shallow-merges a patch and persists it atomically (tmp → rename).
const fs = require('fs');
const path = require('path');

const CONFIG_FILE = path.join(__dirname, '..', '..', 'data', 'engine-config.json');

// Per-channel default posting slots (local HH:MM). Approve defaults an item to
// today at its suggestedSlot (drawn from these).
// TIMEZONE: slots are authored as PACIFIC (Steve's audience-time intent). The
// process must run with TZ=America/Los_Angeles (.env pins it; server.js loadEnv
// applies it before any Date math) — on a UTC box without it, every slot fires
// 7-8h off. Verified: runtime TZ set re-anchors Date correctly.
const DEFAULTS = {
  slots: {
    instagram: ['11:00', '17:00'],
    facebook: ['09:00', '15:00'],
    bluesky: ['10:00'],
    youtube: ['12:00'],
    tiktok: ['18:00'],
    threads: ['12:30'],
    linkedin: ['09:30'],
  },
  productsPerDay: 3,   // how many products the generator picks per day
  lookbackDays: 14,    // how far back to pull candidate products
  noRepeatDays: 14,    // don't re-feature the same product within this window
};

// Per-channel caption character ceilings (used by later agents to trim/validate).
const CHANNEL_LIMITS = {
  bluesky: 280,
  threads: 480,
  instagram: 2200,
  facebook: 2200,
  tiktok: 150,
  youtube: 95,
  linkedin: 2900,
};

// Channels held behind platform App Review — the generator marks these items
// status:'gated' with the reason below until /arm flips them live.
// NOTE: Threads was removed 2026-07-22 — the DW account is a Meta *tester*, so
// threads_content_publish publishes WITHOUT App Review (proven: a Threads item
// posted live via postThreads()). New Threads items are born 'suggested' and
// still gated by the normal per-post approve→schedule step. TikTok stays gated
// (genuinely SELF_ONLY until the app audit lands).
const GATED_CHANNELS = {
  tiktok: 'App Review pending — posts private-only (SELF_ONLY)',
};

function readOverrides() {
  try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
  catch { return {}; }
}

function writeOverrides(obj) {
  try { fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); } catch {}
  const tmp = CONFIG_FILE + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
  fs.renameSync(tmp, CONFIG_FILE);
}

// getConfig() — DEFAULTS with persisted overrides merged over (slots merged one
// level deep so a partial slots override doesn't wipe unspecified channels).
function getConfig() {
  const o = readOverrides();
  return {
    ...DEFAULTS,
    ...o,
    slots: { ...DEFAULTS.slots, ...(o.slots || {}) },
  };
}

// setConfig(patch) — shallow-merge patch into the stored overrides, persist,
// and return the effective merged config.
function setConfig(patch = {}) {
  const o = readOverrides();
  const next = {
    ...o,
    ...patch,
    slots: { ...(o.slots || {}), ...(patch.slots || {}) },
  };
  writeOverrides(next);
  return getConfig();
}

module.exports = { DEFAULTS, CHANNEL_LIMITS, GATED_CHANNELS, getConfig, setConfig };