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