← back to Dw Collections Viewer

server.js

724 lines

// DW Collections Viewer — local-only tool for managing which Shopify collections feed each DW family site.
// Browse all 49 sites with hero thumbnails, attach Shopify collections to a site, regenerate products.json.

// Load local .env (no dotenv dep — minimal inline parser)
(() => {
  const _fs = require('fs'), _p = require('path');
  const envFile = _p.join(__dirname, '.env');
  if (!_fs.existsSync(envFile)) return;
  for (const line of _fs.readFileSync(envFile, 'utf8').split('\n')) {
    const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
    if (!m) continue;
    let v = m[2];
    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
    if (v && !process.env[m[1]]) process.env[m[1]] = v;
  }
})();
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const https = require('https');
const path = require('path');
const os = require('os');
const { execFile } = require('child_process');

const PORT = process.env.PORT || 9887;
const HOME = os.homedir();
const RULES_PATH = path.join(HOME, 'Projects', '_dw-batch', 'curation-rules.json');
const CACHE_DIR = path.join(__dirname, 'data');
const COLL_CACHE = path.join(CACHE_DIR, 'dw-collections-cache.json');
const UPLOADS_DIR = path.join(__dirname, 'public', 'uploads');

fs.mkdirSync(CACHE_DIR, { recursive: true });
fs.mkdirSync(UPLOADS_DIR, { recursive: true });

const SLUG_RE = /^[a-z0-9-]+$/;
const COLL_RE = /^[a-z0-9-]+$/;
function validSlug(s) { return typeof s === 'string' && s.length > 0 && s.length <= 64 && SLUG_RE.test(s); }
function validColl(s) { return typeof s === 'string' && s.length > 0 && s.length <= 96 && COLL_RE.test(s); }

function loadRules() { return JSON.parse(fs.readFileSync(RULES_PATH, 'utf8')); }
function saveRules(r) { fs.writeFileSync(RULES_PATH, JSON.stringify(r, null, 2)); }

function readJsonSafe(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return null; } }

function fetchJson(url, ttlSeconds = 0, cacheKey = null) {
  // Optional disk cache layer
  if (cacheKey) {
    const cp = path.join(CACHE_DIR, cacheKey);
    try {
      const st = fs.statSync(cp);
      if (Date.now() - st.mtimeMs < ttlSeconds * 1000) {
        return Promise.resolve(JSON.parse(fs.readFileSync(cp, 'utf8')));
      }
    } catch {}
  }
  return new Promise((resolve, reject) => {
    https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 dw-collections-viewer' } }, (res) => {
      let d = '';
      res.on('data', c => d += c);
      res.on('end', () => {
        try {
          const parsed = JSON.parse(d);
          if (cacheKey) fs.writeFileSync(path.join(CACHE_DIR, cacheKey), d);
          resolve(parsed);
        } catch (e) { reject(e); }
      });
    }).on('error', reject);
  });
}

// ─── DW Shopify collections list (24hr cached) ───────────────────────────────
async function fetchCollectionsCatalog() {
  // Shopify caps /collections.json at 250 per page, so paginate
  let all = [];
  for (let page = 1; page <= 6; page++) {
    const url = `https://designerwallcoverings.com/collections.json?limit=250&page=${page}`;
    const cacheKey = `collections-page-${page}.json`;
    let data;
    try {
      data = await fetchJson(url, 24 * 3600, cacheKey);
    } catch (e) { break; }
    const cs = data.collections || [];
    if (!cs.length) break;
    all = all.concat(cs);
    if (cs.length < 250) break;
  }
  return all.map(c => ({
    handle: c.handle,
    title: c.title || c.handle,
    products_count: c.products_count || 0,
    description: (c.description || '').slice(0, 240),
    image: c.image && c.image.src ? c.image.src : null,
  }));
}

// ─── Per-collection products fetch ───────────────────────────────────────────
async function fetchCollectionProducts(handle, max = 600) {
  if (!validColl(handle)) throw new Error('invalid collection handle');
  const out = [];
  for (let page = 1; page <= 6 && out.length < max; page++) {
    const url = `https://designerwallcoverings.com/collections/${handle}/products.json?limit=250&page=${page}`;
    let data;
    try { data = await fetchJson(url); } catch (e) { break; }
    const ps = data.products || [];
    if (!ps.length) break;
    out.push(...ps);
    if (ps.length < 250) break;
  }
  return out.slice(0, max);
}

// ─── Site enumeration ────────────────────────────────────────────────────────
// Cache of HTTP status + redirect chain per site, keyed by slug.
// Populated by POST /api/sites/probe and merged into listSites() output.
const SITE_STATUS_PATH = path.join(CACHE_DIR, 'site-status.json');
function loadSiteStatus() { return readJsonSafe(SITE_STATUS_PATH) || {}; }
function saveSiteStatus(obj) { fs.writeFileSync(SITE_STATUS_PATH, JSON.stringify(obj, null, 2)); }

function listSites() {
  const rules = loadRules();
  const status = loadSiteStatus();
  const sites = {};
  const STALE_DAYS = 30;
  const now = Date.now();
  for (const slug of Object.keys(rules.sites)) {
    const rule = rules.sites[slug];
    const fp = path.join(HOME, 'Projects', slug, 'data', 'products.json');
    const products = readJsonSafe(fp) || [];
    const hero = products[0] || {};
    const st = status[slug] || {};
    let lastRenderIso = null, ageDays = null, isStale = false;
    if (fs.existsSync(fp)) {
      try {
        const m = fs.statSync(fp).mtimeMs;
        lastRenderIso = new Date(m).toISOString();
        ageDays = Math.floor((now - m) / 86400000);
        isStale = ageDays >= STALE_DAYS;
      } catch {}
    }
    sites[slug] = {
      slug,
      domain: rule.domain,
      tags_re: rule.tags_re,
      title_re: rule.title_re,
      match_mode: rule.match_mode,
      collections: Array.isArray(rule.shopify_collections) ? rule.shopify_collections : [],
      sort: rule.sort || null,
      hero_sku: rule.hero_sku || null,
      cover_image_url: rule.cover_image_url || null,  // manual drag-drop override
      product_count: products.length,
      last_render_iso: lastRenderIso,
      age_days: ageDays,
      is_stale: isStale,
      hero: hero.image_url ? {
        image_url: hero.image_url,
        title: hero.title || '',
        sku: hero.sku || '',
      } : null,
      hero_history: rule.hero_history || [],  // for visual-diff (most-recent first)
      http_status: st.http_status || null,
      redirect_target: st.redirect_target || null,
      probed_at: st.probed_at || null,
    };
  }
  return sites;
}

// Probe one domain — capture http_status + (if redirect) redirect_target chain end.
function probeOne(domain, timeoutMs = 7000) {
  return new Promise(resolve => {
    if (!domain) return resolve({ http_status: null, redirect_target: null });
    const url = new URL(`https://${domain}/`);
    const req = https.request({
      method: 'HEAD', hostname: url.hostname, path: url.pathname,
      headers: { 'User-Agent': 'dw-collections-viewer/1.0', Accept: '*/*' },
      timeout: timeoutMs,
    }, res => {
      const code = res.statusCode;
      const loc = res.headers.location;
      // Don't follow — surface the immediate redirect target so the UI can show "→ X"
      let redirect_target = null;
      if (loc && code >= 300 && code < 400) {
        try { redirect_target = new URL(loc, `https://${domain}/`).hostname; } catch { redirect_target = String(loc).slice(0, 200); }
      }
      resolve({ http_status: code, redirect_target, probed_at: new Date().toISOString() });
    });
    req.on('error', () => resolve({ http_status: 0, redirect_target: null, probed_at: new Date().toISOString() }));
    req.on('timeout', () => { req.destroy(); resolve({ http_status: 0, redirect_target: 'timeout', probed_at: new Date().toISOString() }); });
    req.end();
  });
}

async function probeAllSites(concurrency = 8) {
  const rules = loadRules();
  const slugs = Object.keys(rules.sites);
  const status = loadSiteStatus();
  let inFlight = 0, idx = 0;
  await new Promise(done => {
    const next = () => {
      while (inFlight < concurrency && idx < slugs.length) {
        const slug = slugs[idx++];
        const domain = rules.sites[slug].domain;
        inFlight++;
        probeOne(domain).then(r => { status[slug] = r; }).catch(() => {}).finally(() => {
          inFlight--;
          if (idx >= slugs.length && inFlight === 0) done(); else next();
        });
      }
    };
    next();
  });
  saveSiteStatus(status);
  return status;
}

// Heuristic seed: suggest collection handles that look related to a slug.
// Called by the viewer when a site has zero assigned collections.
function suggestCollections(slug, allColls) {
  const s = slug.toLowerCase();
  // strip generic suffixes to expose the niche keyword
  const stem = s.replace(/wallpapers?$|wallcoverings?$|walls$/, '');
  const decadeMatch = s.match(/^(\d{4})s/);
  const decade = decadeMatch ? decadeMatch[1] : null;
  const scored = [];
  for (const c of allColls) {
    let score = 0;
    const handle = c.handle.toLowerCase();
    const title = (c.title || '').toLowerCase();
    if (decade) {
      // 1920swallpaper → authentic-1920s-* is the canonical match
      if (handle.includes(`${decade}s`) || title.includes(`${decade}`)) score += 6;
    }
    if (stem && stem.length >= 3) {
      if (handle === stem) score += 10;
      else if (handle.startsWith(stem + '-') || handle.endsWith('-' + stem)) score += 6;
      else if (handle.includes(stem)) score += 3;
      if (title.toLowerCase().includes(stem)) score += 2;
    }
    if (score > 0) scored.push({ ...c, score });
  }
  scored.sort((a, b) => b.score - a.score);
  return scored.slice(0, 8);
}

// ─── Texture/pattern scoring (mirrors textures-first sort) ───────────────────
const TEXTURE = ['grasscloth','grass cloth','silk','linen','jute','raffia','cork','sisal','abaca','mica','textile','fabric','weave','woven','mylar','metallic','silver leaf','gold leaf','suede','velvet','felt','natural fiber','texture','plain','solid','embossed','wallcovering','hand-loomed','handwoven'];
const PATTERN = ['floral','damask','stripe','plaid','geometric','trellis','paisley','chinoiserie','block print','block-print','scenic','mural','scroll','botanical','toile','arabesque','medallion','figural','animal','quatrefoil','greek key','ogee','fretwork','bird','tree','branch','leaf','flower','rose','peony','crane','dragon','heron'];
function texScore(p) {
  const hay = ((p.title || '') + ' ' + (Array.isArray(p.tags) ? p.tags.join(' ') : '')).toLowerCase();
  const t = TEXTURE.reduce((n, k) => n + (hay.includes(k) ? 1 : 0), 0);
  const pp = PATTERN.reduce((n, k) => n + (hay.includes(k) ? 1 : 0), 0);
  return t - pp;
}

// ─── Render: pull products from assigned collections, write site products.json ─
async function renderSite(slug, opts = {}) {
  if (!validSlug(slug)) throw new Error('invalid slug');
  const rules = loadRules();
  const rule = rules.sites[slug];
  if (!rule) throw new Error('unknown slug');
  const collHandles = Array.isArray(rule.shopify_collections) ? rule.shopify_collections : [];
  if (!collHandles.length) throw new Error('no collections assigned to ' + slug);

  const merged = new Map(); // sku → product
  const fetched = {};
  for (const h of collHandles) {
    if (!validColl(h)) continue;
    const ps = await fetchCollectionProducts(h, 800);
    fetched[h] = ps.length;
    for (const p of ps) {
      if (!p.handle || !(p.images && p.images[0] && p.images[0].src)) continue;
      const sku = p.handle;
      if (merged.has(sku)) continue;
      // Apply reject_title filter
      const rejectRe = new RegExp(rules.reject_title || '$^', 'i');
      if (rejectRe.test(p.title || '')) continue;
      let max = 0;
      for (const v of (p.variants || [])) { const x = parseFloat(v.price); if (!isNaN(x) && x > max) max = x; }
      merged.set(sku, {
        sku, handle: p.handle, title: p.title, vendor: (p.vendor || '').trim(),
        product_type: p.product_type, image_url: p.images[0].src, tags: p.tags || [],
        max_price: max, aesthetic: 'all',
        product_url: `https://designerwallcoverings.com/products/${p.handle}`,
        published_at: p.published_at || null,
        created_at: p.created_at || null,
      });
    }
  }

  // Sort: caller picks via opts.sort. Default = textures-first → patterns (legacy behavior).
  // Available: 'textures' (default), 'newest', 'sku', 'title', 'priceUp', 'priceDown', 'random'.
  const all = Array.from(merged.values());
  const sortMode = String(opts.sort || rule.sort || 'textures').toLowerCase();
  const sorters = {
    textures:  (a, b) => (texScore(b) - texScore(a)) || (a.title || '').localeCompare(b.title || ''),
    newest:    (a, b) => String(b.published_at || b.created_at || '').localeCompare(String(a.published_at || a.created_at || '')),
    sku:       (a, b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')),
    title:     (a, b) => String(a.title || '').localeCompare(String(b.title || '')),
    priceUp:   (a, b) => (a.max_price || 0) - (b.max_price || 0),
    priceDown: (a, b) => (b.max_price || 0) - (a.max_price || 0),
    random:    () => Math.random() - 0.5,
  };
  const cmp = sorters[sortMode] || sorters.textures;
  const arr = all.sort(cmp).slice(0, 600);
  console.log(`[render] ${slug} sort=${sortMode} → ${arr.length} products`);
  if (rule.hero_sku) {
    const i = arr.findIndex(p => p.sku === rule.hero_sku);
    if (i > 0) { const [pin] = arr.splice(i, 1); arr.unshift(pin); }
  }

  const dir = path.join(HOME, 'Projects', slug, 'data');
  fs.mkdirSync(dir, { recursive: true });
  // Visual-diff: snapshot the prior hero before overwriting
  const priorPath = path.join(dir, 'products.json');
  const prior = readJsonSafe(priorPath);
  if (prior && prior[0]) {
    rule.hero_history = (rule.hero_history || []).slice(0, 4);
    rule.hero_history.unshift({
      ts: new Date().toISOString(),
      image_url: prior[0].image_url || '',
      title: prior[0].title || '',
      sku: prior[0].sku || '',
    });
    saveRules(rules);
  }
  fs.writeFileSync(priorPath, JSON.stringify(arr, null, 2));

  // Optional: scp + pm2 restart on Kamatera
  let deployed = false, deployErr = null;
  if (opts.deploy) {
    try {
      await new Promise((res, rej) => {
        execFile('scp', ['-q', '-o', 'ConnectTimeout=10',
          path.join(dir, 'products.json'),
          `root@45.61.58.125:/var/www/${slug}.com/data/products.json`,
        ], (err) => err ? rej(err) : res());
      });
      await new Promise((res, rej) => {
        execFile('ssh', ['-o', 'ConnectTimeout=10', 'root@45.61.58.125', `pm2 restart ${slug} >/dev/null 2>&1`], (err) => err ? rej(err) : res());
      });
      deployed = true;
    } catch (e) { deployErr = e.message; }
  }

  return { slug, fetched_per_collection: fetched, total_unique: arr.length, deployed, deploy_error: deployErr };
}

// ─── HTTP API ────────────────────────────────────────────────────────────────
const app = express();
app.use(express.json({ limit: '12mb' }));

app.get('/api/sites', (req, res) => res.json({ sites: listSites() }));

// catalog similarity — proxy to all.designerwallcoverings.com /api/similar.
// Credentials live server-side only (ALLDW_BASIC_AUTH env or default) — NEVER sent to the browser.
const ALLDW_AUTH = 'Basic ' + Buffer.from(process.env.ALLDW_BASIC_AUTH || 'admin:DW2024!').toString('base64');
app.get('/api/similar', (req, res) => {
  const sku = String(req.query.sku || '');
  if (!sku) return res.status(400).json({ error: 'no sku' });
  const qs = new URLSearchParams({ sku });
  if (req.query.limit) qs.set('limit', String(req.query.limit));
  const preq = https.request({
    hostname: 'all.designerwallcoverings.com', path: '/api/similar?' + qs.toString(),
    method: 'GET', timeout: 25000,
    headers: { Authorization: ALLDW_AUTH, 'User-Agent': 'dw-collections-viewer' },
  }, (r) => {
    let body = ''; r.on('data', d => body += d);
    r.on('end', () => { res.status(r.statusCode || 502).type('application/json').send(body); });
  });
  preq.on('timeout', () => preq.destroy(new Error('timeout')));
  preq.on('error', () => res.status(502).json({ error: 'catalog similarity unavailable' }));
  preq.end();
});

// Re-probe every site over HTTPS and cache the immediate redirect target.
// Surfaces parked / consolidated domains so the admin grid can flag them
// instead of pulling the redirect-target's hero and looking like a bug.
app.post('/api/sites/probe', async (_req, res) => {
  try {
    const status = await probeAllSites(8);
    const counts = { ok: 0, redirect: 0, error: 0 };
    for (const v of Object.values(status)) {
      if (v.http_status >= 200 && v.http_status < 300) counts.ok++;
      else if (v.http_status >= 300 && v.http_status < 400) counts.redirect++;
      else counts.error++;
    }
    res.json({ ok: true, counts, total: Object.keys(status).length });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/collections', async (req, res) => {
  try {
    const cs = await fetchCollectionsCatalog();
    res.json({ collections: cs });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.get('/api/site/:slug/suggest', async (req, res) => {
  const { slug } = req.params;
  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
  try {
    const all = await fetchCollectionsCatalog();
    res.json({ suggestions: suggestCollections(slug, all) });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.post('/api/site/:slug/hero', (req, res) => {
  const { slug } = req.params;
  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
  const sku = req.body.sku == null ? null : String(req.body.sku).slice(0, 200);
  const rules = loadRules();
  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });
  if (sku) rules.sites[slug].hero_sku = sku;
  else delete rules.sites[slug].hero_sku;
  saveRules(rules);
  res.json({ ok: true, slug, hero_sku: sku });
});

// Drag-drop cover-image override.
// Accepts a base64 dataURL in {image/jpeg|png|webp|gif}, max ~8MB decoded.
// Persists rule.cover_image_url so the viewer prefers the manual pick over
// the auto-derived hero from products[0].
app.post('/api/site/:slug/cover', (req, res) => {
  const { slug } = req.params;
  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
  const rules = loadRules();
  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });

  const dataUrl = req.body && req.body.dataUrl;
  if (typeof dataUrl !== 'string') return res.status(400).json({ error: 'missing dataUrl' });
  const m = /^data:(image\/(?:jpeg|jpg|png|webp|gif));base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
  if (!m) return res.status(400).json({ error: 'invalid dataUrl (image/jpeg|png|webp|gif only)' });

  const mime = m[1];
  const buf = Buffer.from(m[2], 'base64');
  if (buf.length > 8 * 1024 * 1024) return res.status(413).json({ error: 'image too large (>8MB)' });

  const ext = mime === 'image/jpeg' ? '.jpg' : '.' + mime.split('/')[1];
  const hash = require('crypto').createHash('sha1').update(buf).digest('hex').slice(0, 12);
  const fname = `${slug}-${hash}${ext}`;
  fs.writeFileSync(path.join(UPLOADS_DIR, fname), buf);

  const url = `/uploads/${fname}`;
  rules.sites[slug].cover_image_url = url;
  saveRules(rules);
  res.json({ ok: true, slug, cover_image_url: url, bytes: buf.length });
});

app.delete('/api/site/:slug/cover', (req, res) => {
  const { slug } = req.params;
  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
  const rules = loadRules();
  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });
  delete rules.sites[slug].cover_image_url;
  saveRules(rules);
  res.json({ ok: true, slug, cover_image_url: null });
});

// Auto-seed: for every site with zero collections, run the suggester and assign the top match.
// Reports per-slug what got assigned, what couldn't be matched, so Steve sees the gaps.
app.post('/api/auto-seed', async (req, res) => {
  const dryRun = req.body && req.body.dry_run;
  const minScore = Number.isFinite(req.body && req.body.min_score) ? req.body.min_score : 6;
  try {
    const all = await fetchCollectionsCatalog();
    const rules = loadRules();
    const result = { assigned: [], skipped: [], gaps: [] };
    for (const slug of Object.keys(rules.sites)) {
      const rule = rules.sites[slug];
      const existing = Array.isArray(rule.shopify_collections) ? rule.shopify_collections : [];
      if (existing.length > 0) { result.skipped.push({ slug, reason: 'already has ' + existing.length }); continue; }
      const suggestions = suggestCollections(slug, all);
      const top = suggestions.find(s => s.score >= minScore);
      if (!top) { result.gaps.push({ slug, top_score: suggestions[0]?.score || 0 }); continue; }
      if (!dryRun) {
        rule.shopify_collections = [top.handle];
      }
      result.assigned.push({ slug, handle: top.handle, score: top.score, products: top.products_count });
    }
    if (!dryRun) saveRules(rules);
    res.json({ ok: true, dry_run: !!dryRun, ...result });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// Bulk render: kicks off N renders sequentially (rate-limited to avoid hammering Shopify)
app.post('/api/render-bulk', async (req, res) => {
  const slugs = Array.isArray(req.body.slugs) ? req.body.slugs.filter(validSlug).slice(0, 50) : [];
  const deploy = !!req.body.deploy;
  const sort = String(req.body.sort || '').toLowerCase() || undefined;
  if (!slugs.length) return res.status(400).json({ error: 'no slugs' });
  const results = [];
  for (let i = 0; i < slugs.length; i++) {
    const s = slugs[i];
    try {
      const out = await renderSite(s, { deploy, sort });
      results.push({ slug: s, ok: true, total_unique: out.total_unique, deployed: out.deployed });
    } catch (e) {
      results.push({ slug: s, ok: false, error: e.message });
    }
    if (i < slugs.length - 1) await new Promise(r => setTimeout(r, 1500)); // rate limit
  }
  res.json({ count: slugs.length, results });
});

app.post('/api/site/:slug/collections', (req, res) => {
  const { slug } = req.params;
  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
  const handles = Array.isArray(req.body.collections) ? req.body.collections.filter(validColl).slice(0, 24) : [];
  const rules = loadRules();
  if (!rules.sites[slug]) return res.status(404).json({ error: 'unknown slug' });
  rules.sites[slug].shopify_collections = handles;
  saveRules(rules);
  res.json({ ok: true, slug, collections: handles });
});

app.post('/api/site/:slug/render', async (req, res) => {
  try {
    const slug = req.params.slug;
    const sort = String(req.body.sort || '').toLowerCase() || undefined;
    // Persist the chosen sort back to rules so next render uses the same order without re-picking.
    if (sort && validSlug(slug)) {
      const rules = loadRules();
      if (rules.sites[slug] && rules.sites[slug].sort !== sort) {
        rules.sites[slug].sort = sort;
        saveRules(rules);
      }
    }
    const out = await renderSite(slug, { deploy: !!req.body.deploy, sort });
    res.json({ ok: true, ...out });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// All products for a site, sorted newest-first.
// Pulls from the site's local products.json. Falls back to array order when
// published_at/created_at is absent (legacy renders pre-this-commit).
app.get('/api/site/:slug/products', (req, res) => {
  const { slug } = req.params;
  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
  const fp = path.join(HOME, 'Projects', slug, 'data', 'products.json');
  const products = readJsonSafe(fp) || [];
  const indexed = products.map((p, i) => ({ p, i }));
  indexed.sort((a, b) => {
    const da = Date.parse(a.p.published_at || a.p.created_at || '') || 0;
    const db = Date.parse(b.p.published_at || b.p.created_at || '') || 0;
    if (db !== da) return db - da;
    return a.i - b.i;
  });
  res.json({ slug, count: products.length, products: indexed.map(x => x.p) });
});

// Remove SKUs from a site's local products.json (does NOT touch Shopify).
// Body: { skus: ["SKU1", ...] }. Persists filtered products.json to disk.
app.post('/api/site/:slug/products/remove', (req, res) => {
  const { slug } = req.params;
  if (!validSlug(slug)) return res.status(400).json({ error: 'invalid slug' });
  const skus = Array.isArray(req.body.skus) ? req.body.skus.filter(s => typeof s === 'string' && s) : [];
  if (!skus.length) return res.status(400).json({ error: 'skus[] required' });
  const fp = path.join(HOME, 'Projects', slug, 'data', 'products.json');
  const products = readJsonSafe(fp) || [];
  const before = products.length;
  const skuSet = new Set(skus);
  const filtered = products.filter(p => !skuSet.has(String(p.sku || p.handle || '')));
  const removed = before - filtered.length;
  if (removed > 0) fs.writeFileSync(fp, JSON.stringify(filtered, null, 2));
  res.json({ slug, removed, before, after: filtered.length });
});

// ─── All-products view + bulk delete + bulk add-to-collection ───────────
// Read uses public /products.json (no token). Writes need Admin token in env.
const SHOPIFY_STORE = process.env.SHOPIFY_STORE || 'designerwallcoverings.com';
const SHOPIFY_ADMIN_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_ACCESS_TOKEN || '';

let _allProductsCache = { at: 0, data: null };
async function fetchAllProducts() {
  if (_allProductsCache.data && (Date.now() - _allProductsCache.at) < 5 * 60_000) return _allProductsCache.data;
  const out = [];
  for (let page = 1; page <= 30; page++) {
    const r = await fetch(`https://${SHOPIFY_STORE}/products.json?limit=250&page=${page}`);
    if (!r.ok) break;
    const j = await r.json();
    if (!j.products?.length) break;
    out.push(...j.products);
    if (j.products.length < 250) break;
  }
  _allProductsCache = { at: Date.now(), data: out };
  return out;
}
app.get('/api/products/all', async (req, res) => {
  try {
    const products = await fetchAllProducts();
    const trimmed = products.map(p => ({
      id: p.id, handle: p.handle, title: p.title, vendor: p.vendor,
      product_type: p.product_type, tags: p.tags, published_at: p.published_at,
      image: p.images?.[0]?.src || null,
      price: p.variants?.[0]?.price, status: p.published_at ? 'published' : 'draft'
    }));
    res.json({ count: trimmed.length, products: trimmed, store: SHOPIFY_STORE });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

async function shopifyAdmin(method, path, body) {
  if (!SHOPIFY_ADMIN_TOKEN) throw new Error('SHOPIFY_ADMIN_TOKEN not set in env');
  const url = path.startsWith('https://') ? path : `https://${SHOPIFY_STORE}/admin/api/2024-10${path}`;
  const r = await fetch(url, { method, headers: { 'X-Shopify-Access-Token': SHOPIFY_ADMIN_TOKEN, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
  const text = await r.text();
  if (!r.ok) throw new Error(`shopify ${method} ${path.slice(0,100)} ${r.status}: ${text.slice(0, 200)}`);
  const link = r.headers.get('link') || '';
  const nextMatch = link.match(/<([^>]+)>;\s*rel="next"/);
  const result = text ? JSON.parse(text) : {};
  result._next = nextMatch ? nextMatch[1] : null;
  return result;
}
// Cursor-paginate a Shopify list endpoint (max pages safety cap = 10).
async function shopifyAdminAll(initialPath, listKey, maxPages = 10) {
  let all = [];
  let url = initialPath;
  for (let i = 0; i < maxPages; i++) {
    const r = await shopifyAdmin('GET', url);
    const items = r[listKey] || [];
    all = all.concat(items);
    if (!r._next) break;
    url = r._next;
  }
  return all;
}

app.post('/api/products/delete', async (req, res) => {
  const ids = Array.isArray(req.body.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : [];
  if (!ids.length) return res.status(400).json({ error: 'no ids' });
  const results = [];
  for (const id of ids) {
    try { await shopifyAdmin('DELETE', `/products/${id}.json`); results.push({ id, ok: true }); }
    catch (e) { results.push({ id, ok: false, error: e.message }); }
    await new Promise(r => setTimeout(r, 500)); // 2/sec rate limit
  }
  _allProductsCache = { at: 0, data: null }; // invalidate cache
  res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
});

// All collections (custom + smart) with cursor-based pagination. 5-min cache.
let _collCache = { at: 0, data: null };
app.get('/api/admin/collections', async (req, res) => {
  if (_collCache.data && (Date.now() - _collCache.at) < 5 * 60_000) return res.json(_collCache.data);
  try {
    const customs = await shopifyAdminAll('/custom_collections.json?limit=250', 'custom_collections', 5);
    const smarts  = await shopifyAdminAll('/smart_collections.json?limit=250',  'smart_collections',  5);
    const out = [
      ...customs.map(c => ({ id: c.id, title: c.title, handle: c.handle, products_count: c.products_count, type: 'custom' })),
      ...smarts.map(c => ({ id: c.id, title: c.title, handle: c.handle, products_count: c.products_count, type: 'smart' })),
    ].sort((a, b) => a.title.localeCompare(b.title));
    const data = { collections: out, custom: customs.length, smart: smarts.length };
    _collCache = { at: Date.now(), data };
    res.json(data);
  } catch (e) { res.status(500).json({ error: e.message }); }
});

app.post('/api/products/add-to-collection', async (req, res) => {
  const ids = Array.isArray(req.body.product_ids) ? req.body.product_ids.map(Number).filter(Number.isFinite) : [];
  const collId = Number(req.body.collection_id);
  if (!ids.length || !Number.isFinite(collId)) return res.status(400).json({ error: 'product_ids[] and collection_id required' });
  const results = [];
  for (const pid of ids) {
    try {
      await shopifyAdmin('POST', '/collects.json', { collect: { product_id: pid, collection_id: collId } });
      results.push({ id: pid, ok: true });
    } catch (e) { results.push({ id: pid, ok: false, error: e.message }); }
    await new Promise(r => setTimeout(r, 500));
  }
  res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
});

// Products in a custom_collection — for the "filter to collection" dropdown.
// Uses public products.json (no token needed) — matches Shopify storefront filter.
app.get('/api/admin/collection/:id/products', async (req, res) => {
  try {
    const { id } = req.params;
    if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'numeric id required' });
    // Try custom_collections first, fall back to smart_collections
    let handle = null;
    try {
      const c = await shopifyAdmin('GET', `/custom_collections/${id}.json`);
      handle = c.custom_collection?.handle;
    } catch {}
    if (!handle) {
      try {
        const s = await shopifyAdmin('GET', `/smart_collections/${id}.json`);
        handle = s.smart_collection?.handle;
      } catch {}
    }
    if (!handle) return res.status(404).json({ error: 'collection not found (custom or smart)' });
    const products = await fetchCollectionProducts(handle, 1000);
    res.json({ collection_id: id, handle, count: products.length, ids: products.map(p => p.id) });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// Bulk remove from collection: delete the `collect` join row, leaves product intact.
app.post('/api/products/remove-from-collection', async (req, res) => {
  const ids = Array.isArray(req.body.product_ids) ? req.body.product_ids.map(Number).filter(Number.isFinite) : [];
  const collId = Number(req.body.collection_id);
  if (!ids.length || !Number.isFinite(collId)) return res.status(400).json({ error: 'product_ids[] and collection_id required' });
  const results = [];
  for (const pid of ids) {
    try {
      // Find the collect-row id by listing collects for this product+collection
      const cl = await shopifyAdmin('GET', `/collects.json?product_id=${pid}&collection_id=${collId}`);
      const collect = (cl.collects || []).find(c => Number(c.product_id) === pid && Number(c.collection_id) === collId);
      if (!collect) { results.push({ id: pid, ok: false, error: 'not in collection' }); continue; }
      await shopifyAdmin('DELETE', `/collects/${collect.id}.json`);
      results.push({ id: pid, ok: true });
    } catch (e) { results.push({ id: pid, ok: false, error: e.message }); }
    await new Promise(r => setTimeout(r, 500));
  }
  res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
});

// Server-side rendered standalone "Products" page — no need to touch existing index.html
app.get('/products', (req, res) => res.sendFile(path.join(__dirname, 'public', 'products.html')));

app.use(express.static(path.join(__dirname, 'public')));

app.listen(PORT, '127.0.0.1', () => {
  console.log(`[dw-collections-viewer] http://127.0.0.1:${PORT}/`);
});