← back to 4square Admin

server.js

492 lines

// 4Square-style multi-site admin browser.
// Left rail: data sources (wallco.ai categories + DW Shopify vendors + archives).
// Right pane: product grid with sort + density + bulk actions per Steve's
// CLAUDE.md standing rule ("every product grid MUST have sort + density slider").
// Loopback-bound (127.0.0.1) — admin by convention per wallco-ai admin-gate.
'use strict';
const path = require('path');
const fs   = require('fs');

const WALLCO = '/Users/macstudio3/Projects/wallco-ai';
process.chdir(WALLCO);
const express = require(path.join(WALLCO, 'node_modules', 'express'));
const { Pool } = require(path.join(WALLCO, 'node_modules', 'pg'));

const PORT  = 9906;
const QUEUE = path.join(WALLCO, 'data', 'fliepaper-bugs', 'queue');
const TRASH = path.join(require('os').homedir(), '.wallco-deleted', new Date().toISOString().slice(0,10));
const SPEC  = JSON.parse(fs.readFileSync(path.join(WALLCO, 'data/fliepaper-bugs/spec.json'),'utf8'));
fs.mkdirSync(QUEUE, { recursive: true });
fs.mkdirSync(TRASH, { recursive: true });

const pool      = new Pool({ host: '/tmp', user: 'stevestudio2', database: 'dw_unified' });
const poolPB    = new Pool({ host: '/tmp', user: 'stevestudio2', database: 'patternbank_archive' });
const poolVW    = new Pool({ host: '/tmp', user: 'stevestudio2', database: 'vintage_wallpaper_archive' });
// Gracefully handle "archive db doesn't exist on this machine" by catching at use-site.
const safe = (p, sql, params) => p.query(sql, params).catch(e => ({ _err: e.message, rows: [] }));
const app  = express();
app.use(express.json());

// 404-guard: never serve editor/backup junk even if a static mount lands here later.
app.use((req, _res, next) => {
  if (/\.(bak|orig|rej|swp|swo)(\.|$)|\.pre-|~$/i.test(req.path)) {
    const err = new Error('not found'); err.status = 404; return next(err);
  }
  next();
});

// ── /api/sources ──────────────────────────────────────────────────────
// Tree of selectable data sources for the left rail.
app.get('/api/sources', async (_req, res) => {
  try {
    const [wallcoCats, wallcoTotal, shopifyVendors, shopifyTotal, shopifyByStatus,
           pbTotal, pbCats, vwTotal, vwDecades, fbCells] = await Promise.all([
      pool.query(`SELECT category, COUNT(*)::int AS n FROM spoon_all_designs WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC`),
      pool.query(`SELECT COUNT(*)::int AS n FROM spoon_all_designs`),
      pool.query(`SELECT vendor, COUNT(*)::int AS n FROM shopify_products WHERE vendor IS NOT NULL GROUP BY vendor ORDER BY 2 DESC LIMIT 30`),
      pool.query(`SELECT COUNT(*)::int AS n FROM shopify_products`),
      pool.query(`SELECT status, COUNT(*)::int AS n FROM shopify_products WHERE status IS NOT NULL GROUP BY status ORDER BY 2 DESC`),
      safe(poolPB, `SELECT COUNT(*)::int AS n FROM patterns`),
      safe(poolPB, `SELECT category, COUNT(*)::int AS n FROM patterns WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC LIMIT 20`),
      safe(poolVW, `SELECT COUNT(*)::int AS n FROM products`),
      safe(poolVW, `SELECT era_decade, COUNT(*)::int AS n FROM products WHERE era_decade IS NOT NULL GROUP BY era_decade ORDER BY 1`),
      safe(pool, `SELECT COUNT(*)::int AS n FROM fliepaper_bugs_cells WHERE design_id IS NOT NULL`),
    ]);
    const sources = [
      {
        id: 'wallco', label: 'wallco.ai', kind: 'wallco', total: wallcoTotal.rows[0].n,
        children: [
          { id: 'wallco:all', label: 'all designs', n: wallcoTotal.rows[0].n },
          // Bugs matrix (6 patterns × 6 Gucci colorways = 36 cells). The cells live in
          // all_designs under category 'designer-bugs' (renamed off 'fliepaper-bugs' for the
          // no-FLIEPAPER rule); fliepaper_bugs_cells is the authoritative cell set. Internal
          // source id keeps the scaffold name (exempt) — never rendered customer-facing.
          { id: 'wallco:fliepaper-bugs', label: 'bugs matrix (Gucci)', n: (fbCells.rows && fbCells.rows[0]?.n) || 0, special: 'matrix' },
          ...wallcoCats.rows.map(r => ({ id: `wallco:${r.category}`, label: r.category, n: r.n }))
        ],
      },
      {
        id: 'shopify', label: 'DW Shopify', kind: 'shopify', total: shopifyTotal.rows[0].n, readonly: true,
        children: [
          { id: 'shopify:all', label: 'all products', n: shopifyTotal.rows[0].n },
          ...shopifyByStatus.rows.map(r => ({ id: `shopify:status:${r.status}`, label: `status: ${r.status}`, n: r.n })),
          { id: '__divider__' },
          ...shopifyVendors.rows.map(r => ({ id: `shopify:vendor:${r.vendor}`, label: r.vendor, n: r.n }))
        ],
      },
    ];
    if (pbTotal.rows[0]?.n) {
      sources.push({
        id: 'patternbank', label: 'Patternbank archive', kind: 'patternbank',
        total: pbTotal.rows[0].n, readonly: true, license_note: 'internal-only · owned_by_steve=false',
        children: [
          { id: 'patternbank:all', label: 'all patterns', n: pbTotal.rows[0].n },
          { id: '__divider__' },
          ...pbCats.rows.map(r => ({ id: `patternbank:cat:${r.category}`, label: r.category, n: r.n })),
        ],
      });
    }
    if (vwTotal.rows[0]?.n) {
      sources.push({
        id: 'vintage', label: 'Vintage (Hannah\'s Treasures)', kind: 'vintage',
        total: vwTotal.rows[0].n, readonly: true, license_note: 'internal-only · owned_by_steve=false',
        children: [
          { id: 'vintage:all', label: 'all products', n: vwTotal.rows[0].n },
          { id: '__divider__' },
          ...vwDecades.rows.map(r => ({ id: `vintage:decade:${r.era_decade}`, label: `${r.era_decade}s`, n: r.n })),
        ],
      });
    }
    res.json({ ok: true, sources });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

// ── /api/products ─────────────────────────────────────────────────────
// Unified product fetch. source = "wallco:<cat>" | "wallco:all"
//                              | "shopify:all" | "shopify:vendor:<v>" | "shopify:status:<s>"
function sortClauseWallco(sort) {
  return ({
    'newest':     'created_at DESC',
    'oldest':     'created_at ASC',
    'title':      `COALESCE(tags::text, '') ASC, id ASC`,
    'title-desc': `COALESCE(tags::text, '') DESC, id ASC`,
    'sku':        'dig_number ASC NULLS LAST',
    'sku-desc':   'dig_number DESC NULLS LAST',
    'light':      'dominant_hex ASC NULLS LAST',
    'dark':       'dominant_hex DESC NULLS LAST',
    'category':   'category ASC NULLS LAST, id DESC',
    'published':  'is_published DESC, id DESC',
  })[sort] || 'created_at DESC NULLS LAST';
}
function sortClauseShopify(sort) {
  return ({
    'newest':     'created_at_shopify DESC NULLS LAST',
    'oldest':     'created_at_shopify ASC NULLS LAST',
    'title':      'title ASC NULLS LAST',
    'title-desc': 'title DESC NULLS LAST',
    'sku':        'dw_sku ASC NULLS LAST',
    'sku-desc':   'dw_sku DESC NULLS LAST',
    'price-up':   'price ASC NULLS LAST',
    'price-down': 'price DESC NULLS LAST',
    'vendor':     'vendor ASC NULLS LAST, title ASC',
    'status':     'status ASC NULLS LAST, title ASC',
  })[sort] || 'created_at_shopify DESC NULLS LAST';
}

app.get('/api/products', async (req, res) => {
  try {
    const source = String(req.query.source || 'wallco:all');
    const sort   = String(req.query.sort || 'newest');
    const limit  = Math.min(500, Math.max(20, parseInt(req.query.limit || '120', 10)));
    const offset = Math.max(0, parseInt(req.query.offset || '0', 10));
    const q      = String(req.query.q || '').trim();

    if (source.startsWith('wallco:')) {
      const cat = source.slice(7);
      const orderBy = sortClauseWallco(sort);
      const params = [];
      let where = '1=1';
      if (cat === 'fliepaper-bugs') {
        // 36 matrix cells (authoritative set), regardless of their stored category.
        where = `id IN (SELECT design_id FROM fliepaper_bugs_cells WHERE design_id IS NOT NULL)`;
      } else if (cat !== 'all') { params.push(cat); where = `category = $${params.length}`; }
      if (q) {
        params.push(`%${q}%`);
        where += ` AND (dig_number ILIKE $${params.length} OR category ILIKE $${params.length} OR tags::text ILIKE $${params.length})`;
      }
      const countSql = `SELECT COUNT(*)::int AS n FROM spoon_all_designs WHERE ${where}`;
      const listSql  = `SELECT id, dig_number, tags, local_path, is_published, created_at,
                               dominant_hex, category, prompt
                          FROM spoon_all_designs WHERE ${where}
                         ORDER BY ${orderBy}
                         LIMIT ${limit} OFFSET ${offset}`;
      const [c, r] = await Promise.all([pool.query(countSql, params), pool.query(listSql, params)]);
      // Tag fliepaper-bugs rows with pattern/colorway slugs for matrix view
      if (cat === 'fliepaper-bugs') {
        const PATTERNS = SPEC.patterns.map(p => p.slug);
        const PALETTE  = SPEC.palette.map(c => c.slug);
        for (const row of r.rows) {
          const t = (row.tags || []).join(' ');
          row.pattern  = PATTERNS.find(p => t.includes(p)) || null;
          row.colorway = PALETTE.find(c => t.includes(c)) || null;
          row.is_variant = !row.pattern || !row.colorway;
        }
      }
      return res.json({
        ok: true, kind: 'wallco', source, sort, limit, offset,
        total: c.rows[0].n, has_more: (offset + r.rows.length) < c.rows[0].n,
        rows: r.rows,
        spec: cat === 'fliepaper-bugs' ? SPEC : null,
      });
    }

    if (source.startsWith('shopify:')) {
      const orderBy = sortClauseShopify(sort);
      const parts = source.split(':');
      const params = [];
      let where = '1=1';
      if (parts[1] === 'vendor' && parts[2]) { params.push(parts.slice(2).join(':')); where = `vendor = $${params.length}`; }
      else if (parts[1] === 'status' && parts[2]) { params.push(parts[2]); where = `status = $${params.length}`; }
      if (q) {
        params.push(`%${q}%`);
        const i = params.length;
        where += ` AND (title ILIKE $${i} OR dw_sku ILIKE $${i} OR sku ILIKE $${i} OR handle ILIKE $${i} OR tags ILIKE $${i})`;
      }
      const countSql = `SELECT COUNT(*)::int AS n FROM shopify_products WHERE ${where}`;
      const listSql  = `SELECT id, shopify_id, handle, title, vendor, sku, dw_sku,
                               status, image_url, price, retail_price, cost_price,
                               created_at_shopify, tags
                          FROM shopify_products WHERE ${where}
                         ORDER BY ${orderBy}
                         LIMIT ${limit} OFFSET ${offset}`;
      const [c, r] = await Promise.all([pool.query(countSql, params), pool.query(listSql, params)]);
      return res.json({
        ok: true, kind: 'shopify', source, sort, limit, offset,
        total: c.rows[0].n, has_more: (offset + r.rows.length) < c.rows[0].n,
        rows: r.rows, readonly: true,
      });
    }

    if (source.startsWith('patternbank:')) {
      const parts = source.split(':');
      const params = [];
      let where = '1=1';
      if (parts[1] === 'cat' && parts[2]) { params.push(parts.slice(2).join(':')); where = `category = $${params.length}`; }
      if (q) {
        params.push(`%${q}%`); const i = params.length;
        where += ` AND (title ILIKE $${i} OR designer_display_name ILIKE $${i} OR slug ILIKE $${i} OR tags::text ILIKE $${i})`;
      }
      const orderBy = ({
        'newest':'ingested_at DESC','oldest':'ingested_at ASC',
        'title':'title ASC NULLS LAST','title-desc':'title DESC NULLS LAST',
        'designer':'designer_display_name ASC NULLS LAST',
        'category':'category ASC NULLS LAST',
      })[sort] || 'ingested_at DESC';
      const countSql = `SELECT COUNT(*)::int AS n FROM patterns WHERE ${where}`;
      const listSql  = `SELECT p.pattern_id AS id, p.slug, p.title, p.designer_display_name AS designer,
                               p.category, p.subcategory, p.tags, p.colors_hex, p.is_seamless,
                               p.source_url, p.ingested_at,
                               (SELECT local_path FROM pattern_images i WHERE i.pattern_id=p.pattern_id ORDER BY position NULLS LAST LIMIT 1) AS local_path,
                               (SELECT remote_url FROM pattern_images i WHERE i.pattern_id=p.pattern_id ORDER BY position NULLS LAST LIMIT 1) AS remote_url
                          FROM patterns p WHERE ${where} ORDER BY ${orderBy} LIMIT ${limit} OFFSET ${offset}`;
      const [c, r] = await Promise.all([poolPB.query(countSql, params), poolPB.query(listSql, params)]);
      return res.json({ ok:true, kind:'patternbank', source, sort, limit, offset,
        total: c.rows[0].n, has_more: (offset + r.rows.length) < c.rows[0].n,
        rows: r.rows, readonly: true, license_note: 'patternbank — internal only' });
    }

    if (source.startsWith('vintage:')) {
      const parts = source.split(':');
      const params = [];
      let where = '1=1';
      if (parts[1] === 'decade' && parts[2]) { params.push(parseInt(parts[2],10)); where = `era_decade = $${params.length}`; }
      if (q) {
        params.push(`%${q}%`); const i = params.length;
        where += ` AND (title ILIKE $${i} OR handle ILIKE $${i} OR vendor ILIKE $${i} OR tags::text ILIKE $${i})`;
      }
      const orderBy = ({
        'newest':'ingested_at DESC','oldest':'ingested_at ASC',
        'title':'title ASC NULLS LAST','title-desc':'title DESC NULLS LAST',
        'era':'era_year ASC NULLS LAST','era-desc':'era_year DESC NULLS LAST',
        'vendor':'vendor ASC NULLS LAST',
      })[sort] || 'ingested_at DESC';
      const countSql = `SELECT COUNT(*)::int AS n FROM products WHERE ${where}`;
      const listSql  = `SELECT p.shopify_product_id AS id, p.handle, p.title, p.vendor, p.product_type,
                               p.tags, p.era_decade, p.era_year, p.pattern_width_inches, p.source_url,
                               (SELECT src FROM images i WHERE i.shopify_product_id=p.shopify_product_id ORDER BY position NULLS LAST LIMIT 1) AS image_url
                          FROM products p WHERE ${where} ORDER BY ${orderBy} LIMIT ${limit} OFFSET ${offset}`;
      const [c, r] = await Promise.all([poolVW.query(countSql, params), poolVW.query(listSql, params)]);
      return res.json({ ok:true, kind:'vintage', source, sort, limit, offset,
        total: c.rows[0].n, has_more: (offset + r.rows.length) < c.rows[0].n,
        rows: r.rows, readonly: true, license_note: 'Hannah\'s Treasures — internal only' });
    }

    res.status(400).json({ ok:false, error: 'unknown source' });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

// ── /api/export?source=…&ids=…  CSV export of selected rows ─────────
app.post('/api/export', async (req, res) => {
  const kind = String(req.body?.kind || 'wallco');
  const ids  = (req.body?.ids || []).map(x => kind === 'patternbank' ? String(x) : parseInt(x,10)).filter(Boolean);
  if (!ids.length) return res.status(400).json({ ok:false, error:'ids required' });
  try {
    let rows = [], cols = [];
    if (kind === 'wallco') {
      const r = await pool.query(
        `SELECT id, dig_number, category, is_published, dominant_hex, tags::text AS tags, prompt, created_at
           FROM spoon_all_designs WHERE id=ANY($1)`, [ids]);
      rows = r.rows; cols = ['id','dig_number','category','is_published','dominant_hex','tags','prompt','created_at'];
    } else if (kind === 'shopify') {
      const r = await pool.query(
        `SELECT id, shopify_id, handle, title, vendor, dw_sku, sku, status, price, tags, created_at_shopify
           FROM shopify_products WHERE id=ANY($1)`, [ids]);
      rows = r.rows; cols = ['id','shopify_id','handle','title','vendor','dw_sku','sku','status','price','tags','created_at_shopify'];
    } else if (kind === 'patternbank') {
      const r = await poolPB.query(
        `SELECT pattern_id AS id, slug, title, designer_display_name AS designer, category, source_url, tags::text AS tags, colors_hex::text AS colors_hex
           FROM patterns WHERE pattern_id=ANY($1)`, [ids]);
      rows = r.rows; cols = ['id','slug','title','designer','category','source_url','tags','colors_hex'];
    } else if (kind === 'vintage') {
      const r = await poolVW.query(
        `SELECT shopify_product_id AS id, handle, title, vendor, era_decade, era_year, source_url, tags::text AS tags
           FROM products WHERE shopify_product_id=ANY($1)`, [ids]);
      rows = r.rows; cols = ['id','handle','title','vendor','era_decade','era_year','source_url','tags'];
    } else {
      return res.status(400).json({ ok:false, error:'unknown kind' });
    }
    // Build CSV
    const esc = (v) => {
      if (v === null || v === undefined) return '';
      const s = String(v).replace(/"/g, '""');
      return /[",\n\r]/.test(s) ? `"${s}"` : s;
    };
    const lines = [cols.join(',')].concat(rows.map(r => cols.map(c => esc(r[c])).join(',')));
    const csv = lines.join('\n') + '\n';
    res.type('text/csv; charset=utf-8');
    res.setHeader('Content-Disposition', `attachment; filename="4square-${kind}-${Date.now()}.csv"`);
    res.send(csv);
  } catch (e) {
    res.status(500).json({ ok:false, error: e.message });
  }
});

// ── /api/product/:id?kind=… — full single-row detail for modal ──────
app.get('/api/product/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  const kind = String(req.query.kind || 'wallco');
  if (!Number.isFinite(id)) return res.status(400).json({ ok:false, error:'bad id' });
  try {
    if (kind === 'shopify') {
      const { rows } = await pool.query(
        `SELECT id, shopify_id, handle, title, vendor, product_type, sku, dw_sku, mfr_sku,
                vendor_prefix, supplier_name, tags, status, image_url, price, retail_price, cost_price,
                net_price, created_at_shopify, updated_at_shopify, synced_at,
                full_monty_at, full_monty_rooms, full_monty_tags, fm_metafields_verified, fm_metafields_missing,
                pattern_name, metafields
           FROM shopify_products WHERE id=$1`, [id]);
      if (!rows[0]) return res.status(404).json({ ok:false, error:'not found' });
      return res.json({ ok:true, kind:'shopify', row: rows[0] });
    }
    const { rows } = await pool.query(
      `SELECT id, dig_number, brand, kind, width_in, height_in, panels, generator,
              prompt, negative_prompt, seed, steps, cfg_scale, image_url, local_path,
              dominant_hex, palette, motifs, tags, category, is_published, notes,
              created_at, parent_design_id, request_text, verticals, source_dw_sku, source_url
         FROM spoon_all_designs WHERE id=$1`, [id]);
    if (!rows[0]) return res.status(404).json({ ok:false, error:'not found' });
    return res.json({ ok:true, kind:'wallco', row: rows[0] });
  } catch (e) {
    res.status(500).json({ ok:false, error: e.message });
  }
});

// ── /img/:id?kind=wallco|shopify ──────────────────────────────────────
app.get('/img/:id', async (req, res) => {
  const idRaw = req.params.id;
  const kind = String(req.query.kind || 'wallco');
  try {
    if (kind === 'shopify') {
      const id = parseInt(idRaw, 10);
      const { rows } = await pool.query(`SELECT image_url FROM shopify_products WHERE id=$1`, [id]);
      const u = rows[0]?.image_url;
      if (!u) return res.status(404).send('no image');
      return res.redirect(302, u);
    }
    if (kind === 'patternbank') {
      // id is text pattern_id
      const { rows } = await poolPB.query(
        `SELECT local_path, remote_url FROM pattern_images WHERE pattern_id=$1 ORDER BY position NULLS LAST LIMIT 1`, [idRaw]);
      const r = rows[0];
      if (!r) return res.status(404).send('no image');
      if (r.local_path && fs.existsSync(r.local_path)) {
        res.type(path.extname(r.local_path) === '.jpg' ? 'jpg' : 'png');
        return fs.createReadStream(r.local_path).pipe(res);
      }
      if (r.remote_url) return res.redirect(302, r.remote_url);
      return res.status(404).send('no image');
    }
    if (kind === 'vintage') {
      const id = parseInt(idRaw, 10);
      const { rows } = await poolVW.query(
        `SELECT src FROM images WHERE shopify_product_id=$1 ORDER BY position NULLS LAST LIMIT 1`, [id]);
      const u = rows[0]?.src;
      if (!u) return res.status(404).send('no image');
      return res.redirect(302, u);
    }
    // default wallco
    const id = parseInt(idRaw, 10);
    const { rows } = await pool.query(`SELECT local_path FROM spoon_all_designs WHERE id=$1`, [id]);
    const p = rows[0]?.local_path;
    if (!p || !fs.existsSync(p)) return res.status(404).send('img missing');
    res.type(path.extname(p) === '.jpg' ? 'jpg' : 'png');
    return fs.createReadStream(p).pipe(res);
  } catch (e) {
    res.status(500).send(e.message);
  }
});

// ── bulk writes (wallco.ai only — Shopify is read-only) ───────────────
function parseIds(req, res) {
  const ids = (req.body?.ids || []).map(n => parseInt(n,10)).filter(Number.isFinite);
  if (!ids.length) { res.status(400).json({ ok:false, error:'ids required' }); return null; }
  return ids;
}

app.post('/api/bulk/publish', async (req, res) => {
  const ids = parseIds(req, res); if (!ids) return;
  try {
    const r = await pool.query(`UPDATE spoon_all_designs SET is_published=true WHERE id=ANY($1) RETURNING id`, [ids]);
    res.json({ ok:true, action:'publish', changed: r.rows.length, ids: r.rows.map(x=>x.id) });
  } catch (e) {
    // The settlement DB trigger RAISEs on publish of a BLOCKed design (no
    // allow_override here, intentionally). Return it cleanly instead of crashing.
    const settlement = /settlement/i.test(e.message || '');
    res.status(settlement ? 200 : 500).json({ ok:false, action:'publish', blocked: settlement, error: e.message });
  }
});
app.post('/api/bulk/unpublish', async (req, res) => {
  const ids = parseIds(req, res); if (!ids) return;
  try {
    const r = await pool.query(`UPDATE spoon_all_designs SET is_published=false WHERE id=ANY($1) RETURNING id`, [ids]);
    res.json({ ok:true, action:'unpublish', changed: r.rows.length, ids: r.rows.map(x=>x.id) });
  } catch (e) { res.status(500).json({ ok:false, action:'unpublish', error: e.message }); }
});
app.post('/api/bulk/delete', async (req, res) => {
  const ids = parseIds(req, res); if (!ids) return;
  try {
    const { rows } = await pool.query(`SELECT id, local_path FROM spoon_all_designs WHERE id=ANY($1)`, [ids]);
    const moved = [];
    for (const r of rows) {
      if (r.local_path && fs.existsSync(r.local_path)) {
        const dest = path.join(TRASH, `${r.id}__${path.basename(r.local_path)}`);
        try { fs.renameSync(r.local_path, dest); moved.push(dest); } catch {}
      }
    }
    const d = await pool.query(`DELETE FROM spoon_all_designs WHERE id=ANY($1) RETURNING id`, [ids]);
    res.json({ ok:true, action:'delete', deleted: d.rows.length, files_moved: moved.length, trash_dir: TRASH });
  } catch (e) { res.status(500).json({ ok:false, action:'delete', error: e.message }); }
});
app.post('/api/bulk/tag', async (req, res) => {
  const ids = parseIds(req, res); if (!ids) return;
  const add    = (req.body?.add    || []).map(s => String(s).trim()).filter(Boolean);
  const remove = (req.body?.remove || []).map(s => String(s).trim()).filter(Boolean);
  if (!add.length && !remove.length) return res.status(400).json({ ok:false, error:'add or remove required' });
  let changed = 0;
  try {
    if (add.length) {
      // Append-only-if-not-present per row
      for (const t of add) {
        const r = await pool.query(
          `UPDATE spoon_all_designs SET tags = CASE WHEN $2 = ANY(tags) THEN tags ELSE COALESCE(tags,'{}') || $2 END
            WHERE id=ANY($1) RETURNING id`, [ids, t]);
        changed += r.rows.length;
      }
    }
    if (remove.length) {
      for (const t of remove) {
        await pool.query(`UPDATE spoon_all_designs SET tags = array_remove(tags, $2) WHERE id=ANY($1)`, [ids, t]);
      }
    }
    res.json({ ok:true, action:'tag', ids: ids.length, added: add, removed: remove });
  } catch (e) {
    res.status(500).json({ ok:false, error:e.message });
  }
});

app.post('/api/bulk/recolor', async (req, res) => {
  const ids = parseIds(req, res); if (!ids) return;
  const target = String(req.body?.target_color || '').trim();
  const cw = SPEC.palette.find(c => c.slug === target);
  if (!cw) return res.status(400).json({ ok:false, error:`target_color must be one of ${SPEC.palette.map(c=>c.slug).join(',')}` });
  const { rows } = await pool.query(`SELECT id, tags FROM spoon_all_designs WHERE id=ANY($1) AND id IN (SELECT design_id FROM fliepaper_bugs_cells)`, [ids]);
  const jobs = [];
  for (const r of rows) {
    const t = (r.tags || []).join(' ');
    const pat = SPEC.patterns.find(p => t.includes(p.slug));
    if (!pat) continue;
    const job_id = `${Date.now()}_recolor_${r.id}_${target}`;
    const job = {
      job_id, source: '4square-admin', action: 'recolor', previous_design_id: r.id,
      pattern: pat, colorway: cw, queued_at: new Date().toISOString(),
      note: 'Settlement gate must run before SDXL hit (yolo-runner enforces).',
    };
    fs.writeFileSync(path.join(QUEUE, `${job_id}.json`), JSON.stringify(job, null, 2));
    jobs.push(job_id);
  }
  res.json({ ok:true, action:'recolor', target_color: target, queued: jobs.length, jobs });
});

// ── UI ────────────────────────────────────────────────────────────────
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'index.html')));

app.listen(PORT, '127.0.0.1', () => {
  console.log(`4square admin browser → http://127.0.0.1:${PORT}`);
  console.log(`  trash: ${TRASH}`);
  console.log(`  queue: ${QUEUE}`);
});