← back to Marketing Command Center

server.js

407 lines

#!/usr/bin/env node
// Marketing Command Center — modular dashboard shell.
// Mounts each module from modules/registry.js under /api/<id>, serves its panel
// from public/panels/<id>.{html,js}, and renders a nav shell. Basic-auth gated.
const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');

// Load .env file into process.env (zero-dep; file is the source of truth so pm2
// daemon-env drift can't override it). Existing process.env wins only if a key
// isn't in the file.
(function loadEnv() {
  try {
    for (const line of fs.readFileSync(path.join(__dirname, '.env'), 'utf8').split('\n')) {
      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
      if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
    }
  } catch { /* no .env — use process.env / defaults */ }
})();

const PORT = process.env.PORT || 9661;
const app = express();
// 28mb so the Asset Library can accept base64-encoded image uploads (the assets
// module also mounts its own 28mb parser on /upload as a belt-and-suspenders).
app.use(express.json({ limit: '28mb' }));

// ── Basic auth (matches DW fleet pattern; cred overridable via env) ──────────
const USER = process.env.MCC_USER || 'admin';
// No fallback: fail closed if MCC_PASS is unset so a misconfigured box denies
// all access rather than shipping a known default. (A hardcoded default was
// previously here AND in the committed .env.example — treat that value as burned
// and rotate it; this box must set MCC_PASS in its own .env.)
const PASS = process.env.MCC_PASS || '';
if (!PASS) console.warn('[mcc] MCC_PASS is unset — all requests will be denied until it is configured.');
// Auth accepts the standard Basic header OR an `mcc` cookie we set on first
// success. The cookie is what makes XHR/fetch work after the page is opened
// with embedded URL credentials (http://user:pass@host) — Chrome won't re-attach
// those creds to subresource/fetch requests, so without the cookie every
// /api/* fetch 401s and the shell hangs forever on "Loading…".
app.use((req, res, next) => {
  const hdr = req.headers.authorization || '';
  let b64 = hdr.startsWith('Basic ') ? hdr.slice(6) : '';
  if (!b64) {
    const m = (req.headers.cookie || '').match(/(?:^|;\s*)mcc=([^;]+)/);
    if (m) b64 = decodeURIComponent(m[1]);
  }
  const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
  if (PASS && u === USER && p === PASS) {
    // HttpOnly so XSS can't read the credential-equivalent cookie; Secure only
    // when the request arrived over HTTPS so local plain-http :9661 still works.
    const secure = (req.headers['x-forwarded-proto'] === 'https' || req.secure) ? ' Secure;' : '';
    res.setHeader('Set-Cookie', `mcc=${encodeURIComponent(b64)}; Path=/; Max-Age=604800; HttpOnly;${secure} SameSite=Lax`);
    return next();
  }
  res.set('WWW-Authenticate', 'Basic realm="Marketing Command Center"').status(401).end('Auth required');
});

// ── Mount modules ────────────────────────────────────────────────────────────
const registry = require('./modules/registry');
const panels = [];
for (const id of registry) {
  try {
    const mod = require(`./modules/${id}`);
    const router = express.Router();
    if (typeof mod.mount === 'function') mod.mount(router);
    app.use(`/api/${mod.id || id}`, router);
    panels.push({ id: mod.id || id, title: mod.title || id, icon: mod.icon || '▪' });
    console.log(`[mcc] mounted module: ${mod.id || id}`);
  } catch (e) {
    // A module that isn't built yet shouldn't crash the whole center.
    console.warn(`[mcc] module '${id}' not mounted: ${e.message}`);
    panels.push({ id, title: id, icon: '▪', pending: true });
  }
}

// ── Shell APIs + static ──────────────────────────────────────────────────────
app.get('/api/panels', (_req, res) => res.json({ panels }));
app.get('/api/health', (_req, res) => res.json({ ok: true, panels: panels.length }));

// ── Per-contact notes for the Clients & Prospects panel ──────────────────────
// Server-side store so notes follow the user across devices (the panel's
// localStorage is just an instant cache). Not a module — a notes STORE has no
// nav panel of its own, so it mounts here instead of via the registry.
// File: data/clients-notes.json  →  { "<groupId>": { "<recId>": { text, updated } } }
const NOTES_FILE = path.join(__dirname, 'data', 'clients-notes.json');
const readNotes = () => { try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')); } catch { return {}; } };
const writeNotes = (obj) => { const tmp = NOTES_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(obj)); fs.renameSync(tmp, NOTES_FILE); };
app.get('/api/clients-notes', (req, res) => {
  const group = String(req.query.group || '');
  if (!group) return res.status(400).json({ error: 'group required' });
  const g = readNotes()[group] || {};
  const notes = {};
  for (const id of Object.keys(g)) notes[id] = (g[id] && g[id].text) || '';
  res.json({ group, notes });
});
app.post('/api/clients-notes', (req, res) => {
  const { group, id } = req.body || {};
  let { text } = req.body || {};
  if (!group || !id || typeof group !== 'string' || typeof id !== 'string') return res.status(400).json({ error: 'group and id required' });
  if (group.length > 400 || id.length > 400) return res.status(400).json({ error: 'key too long' });
  text = typeof text === 'string' ? text.slice(0, 8000) : '';
  const all = readNotes();
  const g = all[group] || (all[group] = {});
  if (text.trim()) g[id] = { text, updated: Date.now() };
  else delete g[id];
  if (!Object.keys(g).length) delete all[group];
  try { writeNotes(all); } catch { return res.status(500).json({ error: 'write failed' }); }
  res.json({ ok: true });
});
// ── Canonical DW-owned Instagram accounts (the 35) ───────────────────────────
// One source of truth every social/account panel reads so all owned accounts show
// up everywhere — not just the ones that happen to have posts/data on a surface.
// Not a module (no nav panel of its own), so it mounts here like clients-notes.
// File: data/dw-ig-accounts.json — regenerated by scripts/build-dw-accounts.js.
const DW_ACCOUNTS_FILE = path.join(__dirname, 'data', 'dw-ig-accounts.json');
app.get('/api/dw-accounts', (_req, res) => {
  try {
    const d = JSON.parse(fs.readFileSync(DW_ACCOUNTS_FILE, 'utf8'));
    res.json({ ok: true, count: (d.accounts || []).length, generated_at: d.generated_at || null, accounts: d.accounts || [] });
  } catch (e) {
    res.status(500).json({ ok: false, error: 'dw-accounts unavailable', accounts: [] });
  }
});

// ── Vendor-amplify draft store (Feature B: "Stage drafts") ───────────────────
// A DEDICATED drafts file for the #vendors panel's per-account amplify captions.
// HARD RAIL: this writes ONLY to its own data/vendor-amplify-drafts.json — never
// to the live-authoritative engine-queue.json / channels-outbox.json / meta-pages.json
// (a stale write to those once wiped a live Meta token). Staging here does NOT
// publish anything — actually posting to the selected IG accounts is a gated
// send-to-list action owned by Steve. This endpoint is a review/hand-off staging
// area, nothing more. Not a nav module, so it mounts here like clients-notes.
// Shape: append-only array of
//   { id, staged_at, vendor, postImage, permalink, targetAccounts:[handle],
//     perAccountCaptions:{ handle: text } }
const AMP_DRAFTS_FILE = path.join(__dirname, 'data', 'vendor-amplify-drafts.json');
const readAmpDrafts = () => { try { const d = JSON.parse(fs.readFileSync(AMP_DRAFTS_FILE, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; } };
const writeAmpDrafts = (arr) => {
  fs.mkdirSync(path.dirname(AMP_DRAFTS_FILE), { recursive: true });
  const tmp = AMP_DRAFTS_FILE + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
  fs.renameSync(tmp, AMP_DRAFTS_FILE);   // atomic swap — no partial/corrupt reads
};
app.get('/api/vendor-amplify-drafts', (_req, res) => {
  res.json({ ok: true, drafts: readAmpDrafts().slice().reverse() });
});
app.post('/api/vendor-amplify-drafts', (req, res) => {
  // ── server-side schema validation (codex trap: never trust the client shape) ─
  const b = req.body || {};
  const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
  const postImage = typeof b.postImage === 'string' ? b.postImage.slice(0, 2000) : '';
  const permalink = typeof b.permalink === 'string' ? b.permalink.slice(0, 2000) : '';
  const targetsRaw = Array.isArray(b.targetAccounts) ? b.targetAccounts : [];
  const capsRaw = (b.perAccountCaptions && typeof b.perAccountCaptions === 'object' && !Array.isArray(b.perAccountCaptions)) ? b.perAccountCaptions : {};
  // Normalize handles (strip @, cap length, cap count) and keep only captions for
  // targeted handles. Reject an empty/oversized submission outright.
  const targetAccounts = [...new Set(targetsRaw
    .filter(h => typeof h === 'string')
    .map(h => h.replace(/^@/, '').trim().slice(0, 80))
    .filter(Boolean))].slice(0, 60);
  if (!targetAccounts.length) return res.status(400).json({ ok: false, error: 'no target accounts' });
  const perAccountCaptions = {};
  for (const h of targetAccounts) {
    const t = capsRaw[h];
    perAccountCaptions[h] = (typeof t === 'string' ? t : '').slice(0, 4000);
  }
  const draft = {
    id: 'vad_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
    staged_at: new Date().toISOString(),
    vendor, postImage, permalink, targetAccounts, perAccountCaptions,
  };
  try {
    const all = readAmpDrafts();
    all.push(draft);
    writeAmpDrafts(all.slice(-2000));   // bound the file
  } catch (e) {
    return res.status(500).json({ ok: false, error: 'write failed' });
  }
  res.json({ ok: true, id: draft.id, staged: targetAccounts.length });
});

// ── "Make it ours" creative image system (TK-10842) ──────────────────────────
// Transforms a vendor's pattern image into a GENUINELY DW-ORIGINAL branded asset
// DW can post as its own creative, so amplifying a vendor post never reposts the
// vendor's raw copyrighted photo. Two treatments:
//   Treatment 1 "Branded card" ($0 local) — the client composites the pattern onto
//     a DW-branded canvas (wordmark + hairline frame + availability strip) via the
//     browser Canvas API and POSTs the finished PNG here. Transformative brand
//     creative, no external API. (sharp/canvas aren't installed in this app, so the
//     composite is done client-side per the task's pure-canvas fallback.)
//   Treatment 2 "Room setting" (small $) — server calls the EXISTING DW
//     room-setting-generator pipeline (:8106) to produce an ORIGINAL DW room mockup.
//
// HARD COMPLIANCE GATES (baked in, never skipped):
//  (1) SETTLEMENT — every asset (AI or composite) runs through the settlement
//      post-gen-vision gate (lib/settlement-gate.js, canonical Gemini-vision port)
//      BEFORE it is saved/shown as final. BLOCK/NEEDS_REVIEW => not staged as final;
//      the reason is surfaced.
//  (2) NEVER-FRONT-FACING — restricted (never-front-facing) brands get NO creative
//      controls in the panel; this endpoint also refuses a restricted vendor name.
//  (3) NOTHING AUTO-POSTS — assets save to public/amp-assets/ (gitignored +
//      rsync-excluded so a deploy never clobbers remote-generated assets). Attaching
//      to a staged draft REPLACES the raw vendor photo (draft.dwAsset). No IG/Meta
//      publish call anywhere; engine-queue/channels-outbox/meta-pages untouched.
//  (4) SHOW COST — Treatment 1 = "$0 local"; Treatment 2 = its real per-image rate;
//      paid renders logged via the cost-tracker skill.
const { settlementGateBuffer } = require('./lib/settlement-gate');
const { generateRoom, roomAppUp, ROOM_RENDER_COST } = require('./lib/room-render');

// Same never-front-facing guard the panel uses (kept in sync — extend as policy
// dictates). A restricted vendor must produce NO creative asset here.
const isRestrictedVendor = (name) => /\bschumacher\b/i.test(String(name || ''));

// Saved-asset registry (side-file, gitignored + rsync-excluded) — lets the panel
// list/attach assets and survives a re-render. NOT the live channels/engine store.
const AMP_ASSETS_DIR = path.join(__dirname, 'public', 'amp-assets');
const AMP_ASSETS_INDEX = path.join(__dirname, 'data', 'vendor-amplify-assets.json');
const readAmpAssets = () => { try { const d = JSON.parse(fs.readFileSync(AMP_ASSETS_INDEX, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; } };
const writeAmpAssets = (arr) => {
  fs.mkdirSync(path.dirname(AMP_ASSETS_INDEX), { recursive: true });
  const tmp = AMP_ASSETS_INDEX + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
  fs.renameSync(tmp, AMP_ASSETS_INDEX);   // atomic swap
};
// Persist a produced asset buffer to public/amp-assets/ + register it. Atomic write.
const saveAmpAsset = (buf, ext, meta) => {
  fs.mkdirSync(AMP_ASSETS_DIR, { recursive: true });
  const id = 'amp_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  const fname = `${id}.${ext}`;
  const tmp = path.join(AMP_ASSETS_DIR, fname + '.tmp');
  fs.writeFileSync(tmp, buf);
  fs.renameSync(tmp, path.join(AMP_ASSETS_DIR, fname));
  const rec = Object.assign({ id, file: fname, url: '/amp-assets/' + fname, created_at: new Date().toISOString(), bytes: buf.length }, meta || {});
  const all = readAmpAssets(); all.push(rec); writeAmpAssets(all.slice(-2000));
  return rec;
};
// Best-effort cost log to the cost-tracker skill (never blocks the response).
const logCost = (units, note) => {
  try {
    const { spawn } = require('child_process');
    const script = path.join(os.homedir(), '.claude', 'skills', 'cost-tracker', 'scripts', 'log.js');
    if (!fs.existsSync(script)) return;
    spawn('node', [script, '--api', 'replicate_sdxl', '--units', units, '--app', 'marketing-command-center', '--note', note || 'vendor-amplify make-it-ours'], { detached: true, stdio: 'ignore' }).unref();
  } catch { /* cost log is best-effort */ }
};

// Decode a data: URL or bare base64 into { buffer, mime, ext }. Rejects non-images.
const decodeImage = (dataUrl) => {
  const s = String(dataUrl || '');
  const m = s.match(/^data:(image\/(png|jpeg|jpg|webp));base64,(.+)$/);
  const b64 = m ? m[3] : s.replace(/^data:[^,]*,/, '');
  const mime = m ? m[1] : 'image/png';
  const ext = /jpe?g/.test(mime) ? 'jpg' : (/webp/.test(mime) ? 'webp' : 'png');
  const buffer = Buffer.from(b64, 'base64');
  return { buffer, mime, ext };
};

// Attach a saved DW asset to an existing staged draft, REPLACING the raw vendor
// photo as the image-to-post (draft.dwAsset). Returns {ok, matched}. Never posts.
const attachAssetToDraft = (draftId, asset) => {
  if (!draftId) return { ok: false, matched: false };
  const all = readAmpDrafts();
  let matched = false;
  for (const d of all) {
    if (d && d.id === draftId) {
      d.dwAsset = { url: asset.url, id: asset.id, treatment: asset.treatment || null, settlement: asset.settlement || null, attached_at: new Date().toISOString() };
      d.originalPostImage = d.originalPostImage || d.postImage || '';
      d.postImage = asset.url;   // the DW-original asset is now the image-to-post
      matched = true;
      break;
    }
  }
  if (matched) { try { writeAmpDrafts(all); } catch { return { ok: false, matched: true }; } }
  return { ok: matched, matched };
};

// Treatment 1 — save a client-composited branded card. body:
//   { vendor, treatment:'branded-card', image:<dataURL png>, variant:'1080'|'1350',
//     attachToDraftId?:string }
app.post('/api/vendor-amplify-assets', async (req, res) => {
  const b = req.body || {};
  const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
  if (isRestrictedVendor(vendor)) return res.status(403).json({ ok: false, error: 'restricted brand — no creative allowed' });
  const treatment = (typeof b.treatment === 'string' && b.treatment) ? b.treatment.slice(0, 40) : 'branded-card';
  let img;
  try { img = decodeImage(b.image); } catch { return res.status(400).json({ ok: false, error: 'bad image' }); }
  if (!img.buffer || !img.buffer.length) return res.status(400).json({ ok: false, error: 'empty image' });
  if (img.buffer.length > 12 * 1024 * 1024) return res.status(413).json({ ok: false, error: 'image too large' });

  // (1) SETTLEMENT gate — even the plain composite is vision-checked per the task.
  let gate;
  try { gate = await settlementGateBuffer(img.buffer, img.mime, vendor + ' branded card'); }
  catch (e) { gate = { verdict: 'NEEDS_REVIEW', reason: 'gate error ' + String(e && e.message || e).slice(0, 100), cost: 0 }; }
  if (gate.verdict === 'BLOCK') {
    return res.json({ ok: false, blocked: true, verdict: gate.verdict, reason: gate.reason, cost: gate.cost, costLabel: '$0 (local)' });
  }

  // Save regardless of OK vs NEEDS_REVIEW (NEEDS_REVIEW is a human-eyes flag, not a
  // hard block); the record carries the verdict so the UI + any downstream step can
  // gate the actual (Steve-owned) posting decision on it.
  const asset = saveAmpAsset(img.buffer, img.ext, {
    vendor, treatment, variant: (b.variant === '1350' ? '1080x1350' : '1080x1080'),
    settlement: { verdict: gate.verdict, reason: gate.reason },
  });

  let attach = { ok: false, matched: false };
  if (b.attachToDraftId) attach = attachAssetToDraft(String(b.attachToDraftId).slice(0, 80), Object.assign({ treatment }, asset));

  res.json({
    ok: true,
    asset: { id: asset.id, url: asset.url, variant: asset.variant, bytes: asset.bytes },
    verdict: gate.verdict, reason: gate.reason,
    cost: 0, costLabel: '$0 (local)',   // the composite itself is $0; the gate call is negligible
    attached: attach.matched ? attach.ok : undefined,
  });
});

// Treatment 2 — server-side DW-original room render via the existing pipeline. body:
//   { vendor, patternBase64, roomType?, angle?, cameraDistance?, attachToDraftId? }
app.post('/api/vendor-amplify-room', async (req, res) => {
  const b = req.body || {};
  const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
  if (isRestrictedVendor(vendor)) return res.status(403).json({ ok: false, error: 'restricted brand — no creative allowed' });
  const patternBase64 = typeof b.patternBase64 === 'string' ? b.patternBase64 : '';
  if (!patternBase64) return res.status(400).json({ ok: false, error: 'no pattern image' });

  if (!(await roomAppUp())) {
    return res.status(503).json({ ok: false, error: 'room renderer unavailable — REPLICATE_API_TOKEN not set/reachable', costLabel: `$0 (not billed)` });
  }

  // (2) generate — the room app bills ~$0.01–0.04 once success comes back.
  const gen = await generateRoom(patternBase64, {
    roomType: b.roomType, angle: b.angle, cameraDistance: b.cameraDistance,
    patternWidth: b.patternWidth, patternHeight: b.patternHeight,
  });
  if (!gen.ok) return res.status(502).json({ ok: false, error: gen.error || 'render failed', cost: gen.cost || 0, costLabel: '$0 (not billed)' });
  logCost('1:image', `room render SDXL (${b.roomType || 'living_room'}) for ${vendor}`);
  const costLabel = `$${gen.cost.toFixed(2)} (Replicate SDXL room render)`;

  // (1) SETTLEMENT gate on the AI-generated render — MANDATORY.
  let gate;
  try { gate = await settlementGateBuffer(gen.buffer, gen.mime, vendor + ' room setting'); }
  catch (e) { gate = { verdict: 'NEEDS_REVIEW', reason: 'gate error ' + String(e && e.message || e).slice(0, 100), cost: 0 }; }
  if (gate.verdict === 'BLOCK') {
    // Do NOT save/show a blocked AI asset. Cost was still incurred by the render.
    return res.json({ ok: false, blocked: true, verdict: gate.verdict, reason: gate.reason, cost: gen.cost, costLabel });
  }

  const asset = saveAmpAsset(gen.buffer, 'jpg', {
    vendor, treatment: 'room-setting', variant: '1248x832',
    roomType: b.roomType || 'living_room',
    settlement: { verdict: gate.verdict, reason: gate.reason },
  });
  let attach = { ok: false, matched: false };
  if (b.attachToDraftId) attach = attachAssetToDraft(String(b.attachToDraftId).slice(0, 80), Object.assign({ treatment: 'room-setting' }, asset));

  res.json({
    ok: true,
    asset: { id: asset.id, url: asset.url, variant: asset.variant, bytes: asset.bytes, roomType: asset.roomType },
    verdict: gate.verdict, reason: gate.reason,
    cost: gen.cost, costLabel,
    attached: attach.matched ? attach.ok : undefined,
  });
});

// List saved amplify assets (newest first) — for the panel's creative review area.
app.get('/api/vendor-amplify-assets', (_req, res) => {
  res.json({ ok: true, assets: readAmpAssets().slice().reverse() });
});

// Same-origin image relay so the client Canvas compositor (Treatment 1) can READ
// the vendor pattern pixels without CORS-tainting the canvas (IG/fbcdn images are
// cross-origin and would otherwise block toDataURL). Read-only, image-only,
// size-capped, allowlisted to the known IG/FB CDN + product-image hosts. Not a
// general proxy — refuses anything that isn't an image from an allowed host.
const IMG_PROXY_HOSTS = /(\.cdninstagram\.com|\.fbcdn\.net|instagram\.com|designerwallcoverings\.com|\.shopify\.com|cdn\.shopify\.com|\.licdn\.com)$/i;
app.get('/api/img-proxy', async (req, res) => {
  const raw = String(req.query.u || '');
  let u;
  try { u = new URL(raw); } catch { return res.status(400).end('bad url'); }
  if (!/^https?:$/.test(u.protocol)) return res.status(400).end('bad protocol');
  if (!IMG_PROXY_HOSTS.test(u.hostname)) return res.status(403).end('host not allowed');
  try {
    const c = new AbortController();
    const t = setTimeout(() => c.abort(), 15000);
    const r = await fetch(u.href, { signal: c.signal, redirect: 'follow' });
    clearTimeout(t);
    const ct = (r.headers.get('content-type') || '').split(';')[0];
    if (!r.ok || !/^image\//.test(ct)) return res.status(502).end('not an image');
    const buf = Buffer.from(await r.arrayBuffer());
    if (buf.length > 20 * 1024 * 1024) return res.status(413).end('too large');
    res.set('Content-Type', ct);
    res.set('Cache-Control', 'private, max-age=3600');
    res.set('Access-Control-Allow-Origin', '*');   // same-origin fetch, but explicit for the <img crossorigin>
    res.end(buf);
  } catch (e) {
    res.status(502).end('proxy error');
  }
});

app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));

app.listen(PORT, () => console.log(`[marketing-command-center] http://127.0.0.1:${PORT}  · ${panels.length} panels`));