← back to Dw Launches

server.js

695 lines

'use strict';
/*
 * DW Launches — multi-channel social composer (local build tool)
 * Modeled on Postiz /launches. NO real OAuth, NO live publish, NO deploy.
 * Persists composed launches to data/launches.json.
 *
 * HARD DW RULES enforced here:
 *  - "Wallpaper" is a BANNED word — server rejects it in any saved copy.
 *  - Publish is gated behind an approval flag + explicit Steve sign-off token.
 */
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const mediaLib = require('./media');
const db = require('./db');

const app = express();
const PORT = process.env.PORT || 9764;
const DATA_DIR = path.join(__dirname, 'data');
const STORE = path.join(DATA_DIR, 'launches.json');
const SEED = path.join(DATA_DIR, 'seed-launches.json');

app.use(express.json({ limit: '8mb' }));
app.use(express.static(path.join(__dirname, 'public')));

// ---- DW channels (the 7) -----------------------------------------------
const CHANNELS = [
  { id: 'instagram', name: 'Instagram', connected: true },
  { id: 'facebook', name: 'Facebook', connected: true },
  { id: 'linkedin', name: 'LinkedIn', connected: true },
  { id: 'pinterest', name: 'Pinterest', connected: true },
  { id: 'tiktok', name: 'TikTok', connected: true },
  { id: 'twitter', name: 'X / Twitter', connected: true },
  { id: 'youtube', name: 'YouTube', connected: false } // greyed/stub: needs connect
];

// ---- store helpers ------------------------------------------------------
function ensureStore() {
  if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
  if (!fs.existsSync(STORE)) {
    // seed on first boot if a seed file exists
    if (fs.existsSync(SEED)) {
      fs.copyFileSync(SEED, STORE);
    } else {
      fs.writeFileSync(STORE, JSON.stringify({ launches: [] }, null, 2));
    }
  }
}
function loadStore() {
  ensureStore();
  try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); }
  catch { return { launches: [] }; }
}
function saveStore(db) {
  fs.writeFileSync(STORE, JSON.stringify(db, null, 2));
}

// ---- BANNED WORD gate ---------------------------------------------------
// "Wallpaper" is banned in all copy/titles/fields. Reject any save that leaks it.
function findBannedWord(launch) {
  const hits = [];
  const re = /wallpaper/i;
  const scan = (label, val) => {
    if (typeof val === 'string' && re.test(val)) hits.push(`${label}: "${val.slice(0, 60)}"`);
  };
  scan('global.body', launch?.global?.body);
  Object.entries(launch?.channels || {}).forEach(([ch, c]) => {
    scan(`${ch}.body`, c?.body);
    scan(`${ch}.fields.firstComment`, c?.fields?.firstComment);
    scan(`${ch}.fields.ytTitle`, c?.fields?.ytTitle);
    scan(`${ch}.fields.pinTitle`, c?.fields?.pinTitle);
    scan(`${ch}.fields.board`, c?.fields?.board);
  });
  scan('title', launch?.title);
  return hits;
}

// ---- API ----------------------------------------------------------------
app.get('/api/channels', (_req, res) => res.json({ channels: CHANNELS }));

app.get('/api/launches', (_req, res) => {
  const db = loadStore();
  res.json({ launches: db.launches });
});

app.get('/api/launches/:id', (req, res) => {
  const db = loadStore();
  const l = db.launches.find(x => x.id === req.params.id);
  if (!l) return res.status(404).json({ error: 'not found' });
  res.json({ launch: l });
});

// Save / update a launch (draft or pending-approval). NEVER publishes.
app.post('/api/launches', (req, res) => {
  const incoming = req.body || {};
  const banned = findBannedWord(incoming);
  if (banned.length) {
    return res.status(422).json({
      error: 'BANNED_WORD',
      message: 'The word "Wallpaper" is banned in DW copy. Use "Wallcoverings".',
      hits: banned
    });
  }
  const db = loadStore();
  const now = new Date().toISOString();
  let launch = db.launches.find(x => x.id === incoming.id);
  if (launch) {
    Object.assign(launch, incoming, { updated_at: now });
    // never let the client flip the live-published flag through here
    launch.published = launch.published === true;
  } else {
    launch = {
      id: incoming.id || crypto.randomUUID(),
      title: incoming.title || 'Untitled launch',
      channelsSelected: incoming.channelsSelected || [],
      global: incoming.global || { body: '', media: [] },
      channels: incoming.channels || {},
      schedule: incoming.schedule || { mode: 'draft' },
      status: incoming.status || 'draft', // draft | pending_approval | approved | published
      approval: incoming.approval || { approved: false, by: null, at: null },
      published: false,
      created_at: now,
      updated_at: now
    };
    db.launches.push(launch);
  }
  saveStore(db);
  res.json({ launch });
});

// Move a draft into the approval workflow.
app.post('/api/launches/:id/submit-approval', (req, res) => {
  const db = loadStore();
  const l = db.launches.find(x => x.id === req.params.id);
  if (!l) return res.status(404).json({ error: 'not found' });
  l.status = 'pending_approval';
  l.updated_at = new Date().toISOString();
  saveStore(db);
  res.json({ launch: l });
});

// Steve sign-off. Requires the approval token; flips the gate, NOT live publish.
app.post('/api/launches/:id/approve', (req, res) => {
  const db = loadStore();
  const l = db.launches.find(x => x.id === req.params.id);
  if (!l) return res.status(404).json({ error: 'not found' });
  const token = (req.body && req.body.token) || '';
  // Local sign-off token — this is the "explicit Steve go". Stubbed locally.
  if (token !== 'STEVE-GO') {
    return res.status(403).json({ error: 'NO_SIGNOFF', message: 'Approval requires Steve sign-off token.' });
  }
  l.approval = { approved: true, by: req.body.by || 'Steve', at: new Date().toISOString() };
  l.status = 'approved';
  l.updated_at = new Date().toISOString();
  saveStore(db);
  res.json({ launch: l });
});

// Publish NOW — no sign-off gate. Dispatches live to any channel that has real
// API credentials configured; otherwise records the publish locally and reports
// live:false so the UI tells the truth (no live social channel is connected yet).
app.post('/api/launches/:id/publish', async (req, res) => {
  const db = loadStore();
  const l = db.launches.find(x => x.id === req.params.id);
  if (!l) return res.status(404).json({ error: 'not found' });

  const channels = (l.channelsSelected && l.channelsSelected.length)
    ? l.channelsSelected : Object.keys(l.channels || {});
  const { live, results, note } = await dispatchLive(l, channels);

  l.status = 'published';
  l.published = true;
  l.live = live;
  l.publishResults = results;
  l.publishedAt = new Date().toISOString();
  l.updated_at = l.publishedAt;
  saveStore(db);
  res.json({ launch: l, live, results, note });
});

// Live dispatch. Each channel handler returns { posted, ... }. A post only
// counts as live when the downstream actually posted (not when it simulated),
// so the UI tells the truth instead of pretending.
async function dispatchLive(launch, channels) {
  const results = [];
  let anyLive = false;
  for (const ch of channels) {
    const handler = LIVE_CHANNELS[ch];
    if (!handler) { results.push({ channel: ch, ok: false, skipped: true, reason: 'no live integration for this channel yet' }); continue; }
    try {
      const r = await handler(launch);
      if (r.posted) { anyLive = true; results.push({ channel: ch, ok: true, ...r }); }
      else { results.push({ channel: ch, ok: false, skipped: true, ...r }); }
    } catch (e) {
      results.push({ channel: ch, ok: false, error: String(e.message || e) });
    }
  }
  const liveChans = results.filter(r => r.ok).map(r => r.channel);
  const note = anyLive
    ? `Published live to ${liveChans.join(', ')}.`
    : 'Published — recorded. ' + (results.map(r => r.reason).find(Boolean)
        || 'No live social channel posted (connect a channel to post to real accounts).');
  return { live: anyLive, results, note };
}

// Per-channel live posters. Instagram routes through Norma's instagram-agent
// (Meta Graph Content Publishing) at NORMA_URL. Norma stays in simulation until
// IG creds + its real-post code are enabled — until then this reports posted:false
// with an honest reason rather than faking a live post.
const NORMA_URL = process.env.NORMA_URL || 'http://127.0.0.1:9810';
const NORMA_AUTH = process.env.NORMA_AUTH || 'admin:'; // user:pass for Norma basic-auth
const LIVE_CHANNELS = {
  instagram: async (launch) => {
    const ig = (launch.channels && launch.channels.instagram) || {};
    const media = (ig.media && ig.media.length ? ig.media : (launch.global && launch.global.media)) || [];
    const image_url = (media[0] && media[0].ref) || '';
    const caption = ig.body || (launch.global && launch.global.body) || '';
    let r;
    try {
      r = await fetch(`${NORMA_URL}/api/skill/post`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: 'Basic ' + Buffer.from(NORMA_AUTH).toString('base64'),
        },
        body: JSON.stringify({ image_url, caption }),
      });
    } catch (e) {
      return { posted: false, reason: `Norma (instagram-agent :9810) is unreachable — start it to route Instagram posts (${e.code || e.message}).` };
    }
    if (!r.ok) return { posted: false, reason: `Norma returned HTTP ${r.status}.` };
    const j = await r.json().catch(() => ({}));
    const res = (j && j.result) || {};
    if (res.simulated || res.posted === false) {
      return {
        posted: false,
        simulated: !!res.simulated,
        needs_reconnect: !!res.needs_reconnect,
        permalink: res.permalink || null,
        // Pass through Norma's real reason (e.g. "token expired — reconnect",
        // "no credentials configured") instead of a hardcoded string.
        reason: res.reason || 'Norma did not post (no live Instagram credentials).',
      };
    }
    return { posted: true, permalink: res.permalink || null, media_id: res.media_id || null, via: 'norma' };
  },
};

app.delete('/api/launches/:id', (req, res) => {
  const db = loadStore();
  const before = db.launches.length;
  db.launches = db.launches.filter(x => x.id !== req.params.id);
  saveStore(db);
  res.json({ deleted: before - db.launches.length });
});

// Calendar feed: every scheduled/published per-channel slot, flattened.
app.get('/api/calendar', (_req, res) => {
  const db = loadStore();
  const events = [];
  db.launches.forEach(l => {
    const chans = l.channelsSelected && l.channelsSelected.length ? l.channelsSelected : Object.keys(l.channels || {});
    chans.forEach(ch => {
      const cs = (l.channels && l.channels[ch] && l.channels[ch].schedule) || l.schedule || {};
      if (cs.datetime) {
        events.push({
          launchId: l.id, title: l.title, channel: ch,
          datetime: cs.datetime, mode: cs.mode || l.schedule?.mode,
          status: l.status, published: !!l.published, created_at: l.created_at
        });
      }
    });
  });
  events.sort((a, b) => new Date(a.datetime) - new Date(b.datetime));
  res.json({ events });
});

// Per-channel STREAMS feed — for the Hootsuite-style board. Each channel gets
// its posts bucketed by status (scheduled / pending / published / drafts).
// A launch appears in every channel it targets.
app.get('/api/streams', (_req, res) => {
  const db = loadStore();
  const cols = {};
  CHANNELS.forEach(c => {
    cols[c.id] = { channel: c.id, name: c.name, connected: c.connected,
      scheduled: [], pending: [], published: [], drafts: [] };
  });
  db.launches.forEach(l => {
    const chans = (l.channelsSelected && l.channelsSelected.length)
      ? l.channelsSelected : Object.keys(l.channels || {});
    chans.forEach(ch => {
      if (!cols[ch]) return;
      const cs = (l.channels && l.channels[ch] && l.channels[ch].schedule) || l.schedule || {};
      const body = (l.channels && l.channels[ch] && l.channels[ch].body) || l.global?.body || '';
      const item = {
        launchId: l.id, title: l.title, channel: ch,
        body: body.slice(0, 220), status: l.status,
        published: !!l.published, datetime: cs.datetime || null,
        mode: cs.mode || l.schedule?.mode || null,
        approved: !!(l.approval && l.approval.approved),
        created_at: l.created_at, updated_at: l.updated_at
      };
      // Bucket: published wins, then explicit status, then schedule mode.
      if (l.published || l.status === 'published') cols[ch].published.push(item);
      else if (l.status === 'pending_approval') cols[ch].pending.push(item);
      else if (l.status === 'approved') cols[ch].pending.push(item); // approved = awaiting publish, sits in pending lane
      else if (item.datetime || cs.mode === 'schedule' || cs.mode === 'queue' || cs.mode === 'now') cols[ch].scheduled.push(item);
      else cols[ch].drafts.push(item);
    });
  });
  // sort each lane: scheduled by datetime asc, others by updated desc
  Object.values(cols).forEach(col => {
    col.scheduled.sort((a, b) => new Date(a.datetime || 0) - new Date(b.datetime || 0));
    ['pending', 'published', 'drafts'].forEach(k =>
      col[k].sort((a, b) => new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0)));
  });
  res.json({ columns: CHANNELS.map(c => cols[c.id]) });
});

// Dashboard / overview counts for the command-center landing.
app.get('/api/dashboard', (_req, res) => {
  const db = loadStore();
  const now = new Date();
  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  const endOfToday = new Date(startOfToday); endOfToday.setDate(endOfToday.getDate() + 1);
  const weekAgo = new Date(now); weekAgo.setDate(weekAgo.getDate() - 7);

  let scheduledToday = 0, pendingApproval = 0, publishedThisWeek = 0;
  let drafts = 0, approved = 0, totalLaunches = db.launches.length;
  const perChannel = {};
  CHANNELS.forEach(c => { perChannel[c.id] = { name: c.name, scheduled: 0, pending: 0, published: 0, drafts: 0, total: 0 }; });

  db.launches.forEach(l => {
    if (l.status === 'pending_approval') pendingApproval++;
    if (l.status === 'approved') approved++;
    if (l.status === 'draft') drafts++;
    const chans = (l.channelsSelected && l.channelsSelected.length)
      ? l.channelsSelected : Object.keys(l.channels || {});
    chans.forEach(ch => {
      if (!perChannel[ch]) return;
      perChannel[ch].total++;
      const cs = (l.channels && l.channels[ch] && l.channels[ch].schedule) || l.schedule || {};
      const dt = cs.datetime ? new Date(cs.datetime) : null;
      if (l.published || l.status === 'published') {
        perChannel[ch].published++;
        const pAt = l.publishedSimulatedAt ? new Date(l.publishedSimulatedAt) : (l.updated_at ? new Date(l.updated_at) : null);
        if (pAt && pAt >= weekAgo) publishedThisWeek++;
      } else if (l.status === 'pending_approval' || l.status === 'approved') {
        perChannel[ch].pending++;
        if (dt && dt >= startOfToday && dt < endOfToday) scheduledToday++;
      } else if (dt) {
        perChannel[ch].scheduled++;
        if (dt >= startOfToday && dt < endOfToday) scheduledToday++;
      } else {
        perChannel[ch].drafts++;
      }
    });
  });

  res.json({
    counts: { scheduledToday, pendingApproval, publishedThisWeek, drafts, approved, totalLaunches },
    perChannel: CHANNELS.map(c => Object.assign({ id: c.id, connected: c.connected }, perChannel[c.id])),
    generatedAt: now.toISOString()
  });
});

// ========================================================================
// HOOTSUITE-STYLE STREAMS — user-addable columns (account × stream type),
// including INBOUND streams (mentions / comments / DMs / home / search).
// Inbound data is SIMULATED, generated locally at boot. NO live social API
// is ever called. "Wallpaper" never appears (DW banned word → wallcoverings).
// ========================================================================

// The catalog the UI offers when adding a stream column.
const STREAM_TYPES = [
  { id: 'scheduled', label: 'Scheduled',        kind: 'outbound' },
  { id: 'pending',   label: 'Pending approval', kind: 'outbound' },
  { id: 'published', label: 'Published / Sent', kind: 'outbound' },
  { id: 'drafts',    label: 'Drafts',           kind: 'outbound' },
  { id: 'mentions',  label: 'Mentions',         kind: 'inbound'  },
  { id: 'comments',  label: 'Comments',         kind: 'inbound'  },
  { id: 'dms',       label: 'Direct messages',  kind: 'inbound'  },
  { id: 'home',      label: 'Home feed',        kind: 'inbound'  },
  { id: 'search',    label: 'Keyword / # search', kind: 'search' }
];
app.get('/api/stream-types', (_req, res) =>
  res.json({ types: STREAM_TYPES, channels: CHANNELS }));

// ---- Simulated inbound feed (DW trade-flavored, banned-word clean) -------
const FEED = (() => {
  const authors = [
    ['Atelier Noor', 'atelier.noor', 'designer'],
    ['Hollis & Vane', 'hollisvane', 'studio'],
    ['Margaux Cellier', 'mcellier_design', 'designer'],
    ['The Trade Loft', 'thetradeloft', 'showroom'],
    ['Bryn Castellano', 'bryncast', 'designer'],
    ['Field & Foyer', 'fieldandfoyer', 'studio'],
    ['Pierce Hwang', 'pierce.interiors', 'designer'],
    ['Saorla Quinn', 'saorla.q', 'designer'],
    ['Hewitt Contract', 'hewittcontract', 'contract'],
    ['Lina Värmö', 'lina.varmo', 'stylist'],
    ['Cobalt & Birch', 'cobaltbirch', 'studio'],
    ['Desmond Ailey', 'd.ailey', 'designer']
  ];
  const mentions = [
    'Just spec\'d the grasscloth from @designerwall for a Nob Hill library — clients are obsessed. 🤍',
    'The silk colorways at @designerwall are unreal. Sourcing for a boutique hotel lobby.',
    'Anyone else using @designerwall cork for acoustic treatments? The texture is doing the work.',
    'Tear sheet went straight in the client deck. @designerwall raffia in Oatmeal = chef\'s kiss.',
    'Pulled three @designerwall books for a Tahoe project. Trade pricing made it easy.',
    '@designerwall the metallic flocked line photographs like a dream on site.'
  ];
  const comments = [
    'What\'s the lead time on the Greige colorway in this collection?',
    'Is this available to the trade? I\'m a designer in LA.',
    'Do you offer memo samples? Need to color-match a Celadon scheme.',
    'Gorgeous. Is the wide-width suitable for a commercial corridor?',
    'Price per yard for trade on this one?',
    'Can this ship to a Canadian project address?',
    'Love the Alabaster ground — is there a darker companion in the book?',
    'Does this pass Class A flammability for contract use?'
  ];
  const dms = [
    'Hi — opening a design studio in Santa Monica, how do I set up a trade account?',
    'Following up on my sample request for the cork line, order #4471.',
    'Could you send the full spec PDF for the heritage damask book?',
    'We\'d love to feature your line in our spring showroom — who handles partnerships?'
  ];
  const home = [
    'Trend watch: tonal layered neutrals are dominating 2026 trade orders.',
    'Behind the loom — a look at how hand-dyed grasscloth gets its depth.',
    'Designer spotlight: warm minimalism in a Pacific Heights remodel.',
    'New to the trade program: faster memo-sample turnaround this quarter.',
    'Material study: why cork is the quiet hero of acoustic interiors.'
  ];
  const pools = { mentions, comments, dms, home };
  const colorOf = h => '#' + (Math.abs([...h].reduce((a, c) => a * 31 + c.charCodeAt(0), 7)) % 0xFFFFFF).toString(16).padStart(6, '0');
  const items = [];
  let seq = 0;
  const baseTime = Date.UTC(2026, 5, 19, 16, 0, 0); // stable, no churn
  ['mentions', 'comments', 'dms', 'home'].forEach(kind => {
    const pool = pools[kind];
    // spread across the connected channels
    const chans = CHANNELS.filter(c => c.connected).map(c => c.id);
    pool.forEach((text, i) => {
      const reps = kind === 'comments' ? 2 : 1; // more comments, like real life
      for (let r = 0; r < reps; r++) {
        const ch = chans[(i + r) % chans.length];
        const [name, handle, role] = authors[seq % authors.length];
        items.push({
          id: `feed-${kind}-${seq}`,
          kind, channel: ch,
          author: { name, handle, role, color: colorOf(handle) },
          text,
          datetime: new Date(baseTime - seq * 37 * 60000).toISOString(),
          likes: ((seq * 7) % 40) + 1,
          replies: ((seq * 3) % 9),
          unread: r === 0 && i < 3,
          simulated: true
        });
        seq++;
      }
    });
  });
  return items;
})();

function outboundBucket(type, channel) {
  const db = loadStore();
  const out = [];
  db.launches.forEach(l => {
    const chans = (l.channelsSelected && l.channelsSelected.length)
      ? l.channelsSelected : Object.keys(l.channels || {});
    chans.forEach(ch => {
      if (channel !== 'all' && ch !== channel) return;
      const cs = (l.channels && l.channels[ch] && l.channels[ch].schedule) || l.schedule || {};
      const body = (l.channels && l.channels[ch] && l.channels[ch].body) || l.global?.body || '';
      const item = {
        launchId: l.id, title: l.title, channel: ch, body: body.slice(0, 240),
        status: l.status, published: !!l.published, datetime: cs.datetime || null,
        mode: cs.mode || l.schedule?.mode || null,
        approved: !!(l.approval && l.approval.approved),
        created_at: l.created_at, updated_at: l.updated_at, outbound: true
      };
      const isPub = l.published || l.status === 'published';
      const isPend = l.status === 'pending_approval' || l.status === 'approved';
      const isSched = item.datetime || ['schedule', 'queue', 'now'].includes(cs.mode);
      if (type === 'published' && isPub) out.push(item);
      else if (type === 'pending' && isPend) out.push(item);
      else if (type === 'scheduled' && isSched && !isPub && !isPend) out.push(item);
      else if (type === 'drafts' && !isPub && !isPend && !isSched) out.push(item);
    });
  });
  if (type === 'scheduled') out.sort((a, b) => new Date(a.datetime || 0) - new Date(b.datetime || 0));
  else out.sort((a, b) => new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0));
  return out;
}

// Single-column feed: ?channel=instagram|all &type=mentions &q=keyword
app.get('/api/stream', (req, res) => {
  const channel = req.query.channel || 'all';
  const type = req.query.type || 'scheduled';
  const q = (req.query.q || '').trim().toLowerCase();
  const meta = STREAM_TYPES.find(t => t.id === type) || STREAM_TYPES[0];

  if (meta.kind === 'outbound') {
    return res.json({ channel, type, kind: 'outbound', items: outboundBucket(type, channel) });
  }
  if (type === 'search') {
    // search spans simulated inbound + the user's own outbound copy
    const all = [
      ...FEED.map(f => ({ ...f })),
      ...['published', 'scheduled', 'drafts', 'pending'].flatMap(t => outboundBucket(t, 'all'))
    ];
    const hits = all.filter(it => {
      if (channel !== 'all' && it.channel !== channel) return false;
      if (!q) return true;
      const hay = ((it.text || '') + ' ' + (it.body || '') + ' ' + (it.title || '')).toLowerCase();
      return hay.includes(q.replace(/^#/, ''));
    }).sort((a, b) => new Date(b.datetime || b.created_at || 0) - new Date(a.datetime || a.created_at || 0));
    return res.json({ channel, type, kind: 'search', q, items: hits });
  }
  // inbound (mentions / comments / dms / home)
  const items = FEED.filter(f => f.kind === type && (channel === 'all' || f.channel === channel))
    .sort((a, b) => new Date(b.datetime) - new Date(a.datetime));
  res.json({ channel, type, kind: 'inbound', items });
});

// ---- Unified INBOX — Hootsuite "Inbox" equivalent (simulated) ------------
// Merges every inbound conversation item (mentions + comments + DMs) into one
// triage feed. ?kind=mention|comment|dm  &channel=  — both optional.
app.get('/api/inbox', (req, res) => {
  const kindMap = { mentions: 'mentions', mention: 'mentions', comments: 'comments', comment: 'comments', dms: 'dms', dm: 'dms' };
  const wantKind = kindMap[req.query.kind];
  const channel = req.query.channel || 'all';
  const items = FEED
    .filter(f => ['mentions', 'comments', 'dms'].includes(f.kind))
    .filter(f => !wantKind || f.kind === wantKind)
    .filter(f => channel === 'all' || f.channel === channel)
    .sort((a, b) => new Date(b.datetime) - new Date(a.datetime));
  const counts = { all: 0, mentions: 0, comments: 0, dms: 0, unread: 0 };
  FEED.forEach(f => {
    if (!['mentions', 'comments', 'dms'].includes(f.kind)) return;
    counts.all++; counts[f.kind]++; if (f.unread) counts.unread++;
  });
  res.json({ items, counts, simulated: true });
});

// ---- Analytics (SIMULATED) — Hootsuite "Analyze" equivalent --------------
app.get('/api/analytics', (_req, res) => {
  const db = loadStore();
  // base follower / engagement profiles per channel (stable, illustrative)
  const profile = {
    instagram: { followers: 48210, er: 4.6 }, facebook: { followers: 31140, er: 1.9 },
    linkedin: { followers: 12880, er: 3.1 }, pinterest: { followers: 67400, er: 0.8 },
    tiktok: { followers: 22950, er: 6.2 }, twitter: { followers: 9870, er: 1.1 },
    youtube: { followers: 8420, er: 2.4 }
  };
  const perChannel = CHANNELS.map(c => {
    let posts = 0;
    db.launches.forEach(l => {
      const chans = (l.channelsSelected && l.channelsSelected.length) ? l.channelsSelected : Object.keys(l.channels || {});
      if (chans.includes(c.id) && (l.published || l.status === 'published')) posts++;
    });
    const p = profile[c.id] || { followers: 5000, er: 2 };
    const reach = Math.round(p.followers * (0.32 + (c.id.length % 5) * 0.04));
    const engagement = Math.round(reach * p.er / 100);
    return {
      id: c.id, name: c.name, connected: c.connected,
      followers: p.followers, postsLive: posts, reach, engagement,
      engagementRate: p.er,
      bestTime: ['9:00 AM', '12:30 PM', '6:00 PM', '7:30 PM'][c.id.length % 4] + ' PT'
    };
  });
  // 7-day reach series (stable sine-ish curve, illustrative)
  const series = Array.from({ length: 7 }, (_, i) => ({
    day: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][i],
    reach: Math.round(60000 + Math.sin(i / 1.4) * 22000 + i * 3200)
  }));
  const totals = perChannel.reduce((a, c) => ({
    followers: a.followers + c.followers, reach: a.reach + c.reach, engagement: a.engagement + c.engagement
  }), { followers: 0, reach: 0, engagement: 0 });
  res.json({ simulated: true, perChannel, series, totals });
});

// ---- MEDIA LIBRARY ------------------------------------------------------
// Real assets we have local access to (dw_unified): our Shopify product
// images, our Instagram posts, and vendor Instagram / vendor imagery.
// Read-only. Feeds the composer's attach picker.
app.get('/api/media/sources', async (_req, res) => {
  try {
    const alive = await db.ping();
    if (!alive) return res.status(503).json({ error: 'DB_UNREACHABLE', counts: { shopify: 0, instagram: 0, vendor: 0 } });
    const counts = await mediaLib.counts();
    res.json({
      ok: true,
      counts,
      total: counts.shopify + counts.instagram + counts.vendor,
      sources: [
        { id: 'all', name: 'All media' },
        { id: 'shopify', name: 'Shopify (our products)', count: counts.shopify },
        { id: 'instagram', name: 'Our Instagram', count: counts.instagram },
        { id: 'vendor', name: 'Vendor / IG', count: counts.vendor }
      ]
    });
  } catch (e) {
    res.status(500).json({ error: 'MEDIA_SOURCES_FAILED', message: e.message });
  }
});

app.get('/api/media', async (req, res) => {
  try {
    const { source = 'all', q = '', limit = '60', offset = '0' } = req.query;
    const out = await mediaLib.media({ source, search: q, limit, offset });
    res.json(out);
  } catch (e) {
    res.status(500).json({ error: 'MEDIA_FAILED', message: e.message });
  }
});

// Image proxy + on-disk cache. Lets hotlink-blocked / expiring CDN images
// (vendor IG, vendor sites) actually paint by fetching server-side with a
// browser UA and caching the bytes locally. Read-only fetch of public URLs.
const IMG_CACHE = path.join(DATA_DIR, 'img-cache');
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
app.get('/api/media/img', async (req, res) => {
  const u = req.query.u;
  if (!u || !/^https?:\/\//i.test(u)) return res.status(400).end('bad url');
  try {
    if (!fs.existsSync(IMG_CACHE)) fs.mkdirSync(IMG_CACHE, { recursive: true });
    const key = crypto.createHash('sha1').update(u).digest('hex');
    const hit = fs.readdirSync(IMG_CACHE).find(f => f.startsWith(key + '.'));
    if (hit) {
      res.set('Cache-Control', 'public, max-age=604800');
      return res.sendFile(path.join(IMG_CACHE, hit));
    }
    let ref;
    try { ref = new URL(u).origin + '/'; } catch { ref = undefined; }
    const r = await fetch(u, { headers: { 'User-Agent': UA, ...(ref ? { Referer: ref } : {}), Accept: 'image/*,*/*' } });
    if (!r.ok) return res.status(502).end('upstream ' + r.status);
    const ct = r.headers.get('content-type') || 'image/jpeg';
    if (!/^image\//.test(ct)) return res.status(415).end('not an image');
    const buf = Buffer.from(await r.arrayBuffer());
    const ext = ct.includes('png') ? 'png' : ct.includes('webp') ? 'webp' : ct.includes('gif') ? 'gif' : 'jpg';
    fs.writeFileSync(path.join(IMG_CACHE, key + '.' + ext), buf);
    res.set('Content-Type', ct);
    res.set('Cache-Control', 'public, max-age=604800');
    res.end(buf);
  } catch (e) {
    res.status(502).end('proxy error');
  }
});

// ---- VENDOR INSTAGRAM ingest -------------------------------------------
// Populate the vendor-IG store with real posts. Two paths:
//  - graph:      Meta business_discovery (needs META_ACCESS_TOKEN + IG_USER_ID)
//  - permalinks: public /embed/ extraction for a list of post URLs (no token)
const vendorIg = require('./vendor-ig');
app.get('/api/vendor-ig/handles', (_req, res) => {
  res.json({ handles: vendorIg.handles(), posts: vendorIg.count() });
});
// serve locally-saved vendor-IG post screenshots (CAPTURE=shot ingest path)
app.get('/api/vendor-ig/img/:id', (req, res) => {
  const id = String(req.params.id).replace(/[^A-Za-z0-9_-]/g, '');
  const f = path.join(DATA_DIR, 'img-cache', 'vig_' + id + '.jpg');
  if (!fs.existsSync(f)) return res.status(404).end();
  res.set('Cache-Control', 'public, max-age=604800');
  res.sendFile(f);
});
app.post('/api/vendor-ig/ingest-graph', async (_req, res) => {
  try {
    if (!process.env.META_ACCESS_TOKEN || !process.env.IG_USER_ID)
      return res.status(400).json({ error: 'NO_TOKEN', message: 'Set META_ACCESS_TOKEN and IG_USER_ID (our IG Business account) to ingest via Meta Graph.' });
    const out = await vendorIg.ingestViaGraph({});
    res.json({ ok: true, ...out });
  } catch (e) { res.status(500).json({ error: 'INGEST_FAILED', message: e.message }); }
});
app.post('/api/vendor-ig/ingest-permalinks', async (req, res) => {
  try {
    const urls = (req.body && req.body.urls) || [];
    if (!Array.isArray(urls) || !urls.length)
      return res.status(400).json({ error: 'NO_URLS', message: 'POST { urls: ["https://instagram.com/p/...", ...] }' });
    const out = await vendorIg.ingestPermalinks(urls);
    res.json({ ok: true, ...out });
  } catch (e) { res.status(500).json({ error: 'INGEST_FAILED', message: e.message }); }
});

app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT }));

app.listen(PORT, () => {
  ensureStore();
  console.log(`DW Launches composer on http://127.0.0.1:${PORT}`);
});