← back to Filemaker Mcp
dedup-review-server.mjs
123 lines
// GRS dedup review + selection viewer. Basic auth admin/DW2024!. Lists the 111 CLIENT-SAFE
// duplicate masters; Steve selects 1..all and deletes. Saves each record BEFORE delete (reversible).
import http from 'http';
import https from 'https';
import fs from 'fs';
import path from 'path';
const env = JSON.parse(fs.readFileSync('/tmp/fmenv.json', 'utf8'));
for (const [k, v] of Object.entries(env)) process.env[k] = v;
// Shopify admin token (for archiving products on delete)
for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n')) { const m = l.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); if (m && !(m[1] in process.env)) process.env[m[1]] = m[2]; }
const SHOP_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN, SHOP_DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const fm = await import('./src/fm-client.js');
const DB = 'WALLPAPER', LAYOUT = '*List Wallpapers - Full View';
const rows = JSON.parse(fs.readFileSync('./data/grs-dedup-review.json', 'utf8'));
const RESTORE = process.env.HOME + '/Desktop/GRS_dedup_viewer_RESTORE_20260819.jsonl';
const ARCHIVE_RESTORE = process.env.HOME + '/Desktop/GRS_dedup_viewer_ARCHIVE_RESTORE_20260819.jsonl';
function shopGql(query, variables) {
return new Promise((res, rej) => { const b = JSON.stringify({ query, variables });
const r = https.request({ host: SHOP_DOMAIN, path: '/admin/api/2024-10/graphql.json', method: 'POST', headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } },
x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { res(JSON.parse(d)); } catch (e) { rej(new Error(d)); } }); });
r.on('error', rej); r.write(b); r.end(); });
}
// archive a Shopify product, saving its old status first (reversible)
async function archiveProduct(pid) {
const gid = `gid://shopify/Product/${pid}`;
const cur = await shopGql(`{ product(id:"${gid}"){ id status } }`);
const old = cur?.data?.product?.status;
if (!old) return { pid, ok: false, err: 'product not found' };
if (old === 'ARCHIVED') return { pid, ok: true, already: true };
fs.appendFileSync(ARCHIVE_RESTORE, JSON.stringify({ ts: new Date().toISOString(), product_id: pid, old_status: old }) + '\n');
const up = await shopGql(`mutation($id:ID!){ productUpdate(input:{id:$id,status:ARCHIVED}){ userErrors{ message } } }`, { id: gid });
const ue = up?.data?.productUpdate?.userErrors;
if (ue && ue.length) return { pid, ok: false, err: ue[0].message };
return { pid, ok: true, from: old };
}
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
const PORT = 9799;
const page = () => `<!doctype html><html><head><meta charset=utf8><title>GRS Dedup Review</title>
<style>
body{font:14px/1.4 -apple-system,system-ui,sans-serif;margin:0;background:#faf9f7;color:#222}
header{position:sticky;top:0;background:#1a1a1a;color:#fff;padding:12px 18px;display:flex;gap:16px;align-items:center;flex-wrap:wrap;z-index:5}
header h1{font-size:16px;margin:0;font-weight:600}
.pill{background:#333;border-radius:20px;padding:4px 12px;font-size:13px}
button{font:inherit;padding:7px 14px;border:0;border-radius:7px;cursor:pointer}
.go{background:#c0392b;color:#fff;font-weight:600}.go:disabled{opacity:.4;cursor:not-allowed}
.sel{background:#eee}
table{border-collapse:collapse;width:100%;background:#fff}
th,td{padding:7px 10px;border-bottom:1px solid #eee;text-align:left;font-size:13px}
th{background:#f0efec;position:sticky;top:52px;cursor:pointer}
tr:hover{background:#fcfbf9}
.del{color:#c0392b;font-family:ui-monospace,monospace}.keep{color:#27ae60;font-family:ui-monospace,monospace}
code{font-family:ui-monospace,monospace;background:#f3f2ef;padding:1px 5px;border-radius:4px}
#log{padding:10px 18px;font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap;max-height:220px;overflow:auto;background:#111;color:#0f0;display:none}
</style></head><body>
<header>
<h1>GRS Duplicate Masters — Client-Safe</h1>
<span class=pill id=total>${rows.length} rows</span>
<span class=pill id=count>0 selected</span>
<button class=sel onclick="all(true)">Select all</button>
<button class=sel onclick="all(false)">Clear</button>
<button class=go id=go disabled onclick="del()">Flag dup + Archive on Shopify</button>
<span style="font-size:12px;opacity:.7">No client acct# / no sample date. API delete is blocked by FileMaker — so this FLAGS the dup master (Special Notes “DUP-REMOVE”) + archives its Shopify product. Both reversible.</span>
</header>
<div id=log></div>
<table><thead><tr>
<th><input type=checkbox onclick="all(this.checked)"></th>
<th>DW SKU</th><th>Mfr code</th><th>Pattern name</th><th>Net</th>
<th>Delete record</th><th>Keeper (kept)</th><th>Shopify</th></tr></thead><tbody id=tb></tbody></table>
<script>
const rows=${JSON.stringify(rows)};
const tb=document.getElementById('tb');
rows.forEach((r,i)=>{const tr=document.createElement('tr');
tr.innerHTML='<td><input type=checkbox class=cb data-i="'+i+'"></td>'+
'<td><b>'+r.dw_sku+'</b></td><td><code>'+(r.mfr||'')+'</code></td><td>'+(r.name||'—')+'</td>'+
'<td>'+(r.net||'')+'</td><td class=del>'+r.delete_record_id+'</td><td class=keep>'+r.keeper_record_id+' <code>'+(r.keeper_mfr||'')+'</code></td>'+
'<td>'+(r.shopify_product_ids&&r.shopify_product_ids.length?('<span style="color:#c0392b">will archive '+r.shopify_product_ids.length+'</span>'):'<span style="opacity:.4">none</span>')+'</td>';
tb.appendChild(tr);});
const cbs=[...document.querySelectorAll('.cb')];
function upd(){const n=cbs.filter(c=>c.checked).length;document.getElementById('count').textContent=n+' selected';document.getElementById('go').disabled=!n;}
cbs.forEach(c=>c.onchange=upd);
function all(v){cbs.forEach(c=>c.checked=v);upd();}
async function del(){const sel=cbs.filter(c=>c.checked).map(c=>rows[+c.dataset.i]);
const arch=sel.reduce((n,s)=>n+(s.shopify_product_ids?s.shopify_product_ids.length:0),0);
if(!confirm('Flag '+sel.length+' duplicate master(s) as DUP-REMOVE and archive '+arch+' Shopify product(s)? Reversible.'))return;
const log=document.getElementById('log');log.style.display='block';log.textContent='Processing '+sel.length+'...\\n';
const res=await fetch('/api/delete',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({items:sel.map(s=>({recId:s.delete_record_id,keeper:s.keeper_record_id,productIds:s.shopify_product_ids||[]}))})});
const j=await res.json();
log.textContent+='\\nDONE: '+j.ok+' flagged, '+j.fail+' failed.\\n'+j.results.map(r=>(r.ok?' OK ':' FAIL ')+r.id+(r.ok?(' (Shopify archived: '+r.archived+')'):'')+(r.err?' '+r.err:'')).join('\\n');
sel.forEach((s,k)=>{if(j.results.find(r=>r.id===s.delete_record_id&&r.ok)){cbs.find(c=>+c.dataset.i===rows.indexOf(s)).closest('tr').style.opacity=.3;}});
}
</script></body></html>`;
http.createServer(async (req, res) => {
if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm=dedup' }); return res.end('auth'); }
if (req.method === 'GET' && req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page()); }
if (req.method === 'POST' && req.url === '/api/delete') {
let body = ''; req.on('data', c => body += c); req.on('end', async () => {
const { items } = JSON.parse(body || '{}'); const results = [];
const today = '2026-08-19';
for (const it of (items || [])) {
const id = it.recId; let archived = [];
try {
// API DELETE is blocked by FileMaker's [110] cascade, so we MARK the dup master
// (reversible: clear Special Notes) instead of deleting, then archive its Shopify product.
const g = await fm.getRecord(DB, LAYOUT, id).catch(() => null);
fs.appendFileSync(RESTORE, JSON.stringify({ ts: new Date().toISOString(), recId: id, old_special_notes: g?.fieldData?.['Special Notes'] ?? '', fieldData: g?.fieldData || null }) + '\n');
await fm.updateRecord(DB, LAYOUT, id, { 'Special Notes': `DUP-REMOVE keeper:${it.keeper || ''} ${today}` }, { dryRun: false });
// then archive the Shopify product(s) for this SKU ("if deleted, archive too")
for (const pid of (it.productIds || [])) { archived.push(await archiveProduct(pid)); }
const archOk = archived.filter(a => a.ok).length;
results.push({ id, ok: true, archived: archOk, archErr: archived.filter(a => !a.ok).map(a => a.err) });
} catch (e) { results.push({ id, ok: false, err: String(e.message || e).slice(0, 80) }); }
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: results.filter(r => r.ok).length, fail: results.filter(r => !r.ok).length, results }));
});
return;
}
res.writeHead(404); res.end('nf');
}).listen(PORT, '127.0.0.1', () => console.log(`dedup review viewer -> http://127.0.0.1:${PORT} (admin/DW2024!)`));