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