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