← back to Dwha Harlequin Onboard
DWHA Harlequin onboard infrastructure — TK-10882
9d75a2efdab28952f2ac64da65ea023c4622ceed · 2026-08-28 04:09:17 -0700 · Steve
631 viable rows confirmed (25/day cadence, ~26 days). All products created
as DRAFT only; active publish is Steve-gated. Settlement gate required for
29 floral/bird/tropical-flagged rows before any ACTIVE publish.
Scripts: onboard-batch.mjs (main), rollback.mjs (undo), check-coverage.mjs (dry-run).
LaunchAgent plist drafted to PENDING-APPROVAL (not installed). Token sourced
from secrets-manager at runtime, not stored in plist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
A .gitignoreA PENDING-APPROVAL-launchd.mdA launchd/com.steve.dwha-onboard-daily.plistA package-lock.jsonA package.jsonA scripts/check-coverage.mjsA scripts/onboard-batch.mjsA scripts/rollback.mjs
Diff
commit 9d75a2efdab28952f2ac64da65ea023c4622ceed
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 28 04:09:17 2026 -0700
DWHA Harlequin onboard infrastructure — TK-10882
631 viable rows confirmed (25/day cadence, ~26 days). All products created
as DRAFT only; active publish is Steve-gated. Settlement gate required for
29 floral/bird/tropical-flagged rows before any ACTIVE publish.
Scripts: onboard-batch.mjs (main), rollback.mjs (undo), check-coverage.mjs (dry-run).
LaunchAgent plist drafted to PENDING-APPROVAL (not installed). Token sourced
from secrets-manager at runtime, not stored in plist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
.gitignore | 10 +
PENDING-APPROVAL-launchd.md | 38 +++
launchd/com.steve.dwha-onboard-daily.plist | 52 ++++
package-lock.json | 161 ++++++++++
package.json | 16 +
scripts/check-coverage.mjs | 182 +++++++++++
scripts/onboard-batch.mjs | 472 +++++++++++++++++++++++++++++
scripts/rollback.mjs | 160 ++++++++++
8 files changed, 1091 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7d23f33
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+images/
+restore-maps/
+data/cadence-counter.json
diff --git a/PENDING-APPROVAL-launchd.md b/PENDING-APPROVAL-launchd.md
new file mode 100644
index 0000000..f941432
--- /dev/null
+++ b/PENDING-APPROVAL-launchd.md
@@ -0,0 +1,38 @@
+# PENDING APPROVAL: Install DWHA Harlequin Daily Onboard LaunchAgent
+**TK-10882** | Agent: vp-dw-commerce | Created: 2026-08-28 04:08 AM
+
+## Action
+Install launchd plist for DWHA Harlequin 25/day onboard cadence (05:00 AM daily).
+
+```sh
+cp ~/Projects/dwha-harlequin-onboard/launchd/com.steve.dwha-onboard-daily.plist \
+ ~/Library/LaunchAgents/com.steve.dwha-onboard-daily.plist
+
+launchctl bootstrap gui/$(id -u) \
+ ~/Library/LaunchAgents/com.steve.dwha-onboard-daily.plist
+```
+
+## What it does
+- Fires daily at 05:00 AM: `node scripts/onboard-batch.mjs`
+- Reads harlequin_catalog for viable rows not yet on Shopify
+- Downloads images from harlequin.design CDN to local disk (cached)
+- Creates 25 Shopify DRAFT products per day — never ACTIVE
+- Saves restore-map per product; marks on_shopify=true in DB
+- Stops at the 25/day cadence cap via data/cadence-counter.json
+
+## Coverage (verified 2026-08-28)
+- 631 rows READY (dw_sku + trade price + image, not discontinued)
+- 29 settlement-flagged (require settlement gate before ACTIVE)
+- 602 clean (no settlement risk)
+- Cadence plan: 631 / 25 = ~26 days to full onboard
+- Price range: $168.33 to $981 retail
+
+## Spend
+$0 — local HTTP downloads + Shopify REST (no per-call cost under plan)
+
+## Rollback
+node ~/Projects/dwha-harlequin-onboard/scripts/rollback.mjs DWHA-XXXXXX
+node ~/Projects/dwha-harlequin-onboard/scripts/rollback.mjs --all
+
+## APPROVE: run the cp + launchctl commands above
+## BLOCK: ignore this file
diff --git a/launchd/com.steve.dwha-onboard-daily.plist b/launchd/com.steve.dwha-onboard-daily.plist
new file mode 100644
index 0000000..0508a09
--- /dev/null
+++ b/launchd/com.steve.dwha-onboard-daily.plist
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.dwha-onboard-daily</string>
+
+ <key>ProgramArguments</key>
+ <array>
+ <string>/usr/local/bin/node</string>
+ <string>/Users/macstudio3/Projects/dwha-harlequin-onboard/scripts/onboard-batch.mjs</string>
+ </array>
+
+ <!-- Runs daily at 05:00 AM local time -->
+ <key>StartCalendarInterval</key>
+ <dict>
+ <key>Hour</key>
+ <integer>5</integer>
+ <key>Minute</key>
+ <integer>0</integer>
+ </dict>
+
+ <!-- Env: token is read at runtime from secrets-manager .env (not stored here) -->
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>HOME</key>
+ <string>/Users/macstudio3</string>
+ <key>PATH</key>
+ <string>/usr/local/bin:/usr/bin:/bin</string>
+ </dict>
+
+ <key>WorkingDirectory</key>
+ <string>/Users/macstudio3/Projects/dwha-harlequin-onboard</string>
+
+ <key>StandardOutPath</key>
+ <string>/Users/macstudio3/Projects/dwha-harlequin-onboard/data/launchd-stdout.log</string>
+
+ <key>StandardErrorPath</key>
+ <string>/Users/macstudio3/Projects/dwha-harlequin-onboard/data/launchd-stderr.log</string>
+
+ <!-- Restart on crash (not on normal exit) -->
+ <key>KeepAlive</key>
+ <dict>
+ <key>Crashed</key>
+ <true/>
+ </dict>
+
+ <!-- Run missed jobs if machine was asleep at 05:00 -->
+ <key>RunAtLoad</key>
+ <false/>
+</dict>
+</plist>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..28d98ee
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,161 @@
+{
+ "name": "dwha-harlequin-onboard",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dwha-harlequin-onboard",
+ "version": "1.0.0",
+ "dependencies": {
+ "pg": "^8.11.0"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..831aa3e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "dwha-harlequin-onboard",
+ "version": "1.0.0",
+ "description": "DWHA Harlequin 25/day Shopify onboard pipeline — TK-10882",
+ "type": "module",
+ "scripts": {
+ "check": "node scripts/check-coverage.mjs",
+ "check-verbose": "node scripts/check-coverage.mjs --verbose",
+ "dry-run": "node scripts/onboard-batch.mjs --dry-run",
+ "onboard": "node scripts/onboard-batch.mjs",
+ "rollback-all-dry": "node scripts/rollback.mjs --all --dry-run"
+ },
+ "dependencies": {
+ "pg": "^8.11.0"
+ }
+}
diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs
new file mode 100644
index 0000000..020e435
--- /dev/null
+++ b/scripts/check-coverage.mjs
@@ -0,0 +1,182 @@
+#!/usr/bin/env node
+/**
+ * DWHA Harlequin Check Coverage — check-coverage.mjs
+ * TK-10882 — VP DW Commerce
+ *
+ * Dry-run audit: shows exactly which harlequin_catalog rows are onboard-ready
+ * vs skipped, and why. No writes. No Shopify calls. No image downloads.
+ *
+ * Usage:
+ * node scripts/check-coverage.mjs # summary
+ * node scripts/check-coverage.mjs --verbose # full row list
+ * node scripts/check-coverage.mjs --json # machine-readable JSON
+ */
+
+import pg from 'pg';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = path.resolve(__dirname, '..');
+const RESTORE_DIR = path.join(PROJECT_ROOT, 'restore-maps');
+
+const IS_VERBOSE = process.argv.includes('--verbose');
+const IS_JSON = process.argv.includes('--json');
+
+function computeRetailPrice(price_trade) {
+ const raw = parseFloat(price_trade) / 0.65 / 0.85;
+ return Math.round(raw * 100) / 100;
+}
+
+// Settlement-risk keywords (floral/bird/tropical require settlement gate before ACTIVE)
+const SETTLEMENT_KEYWORDS = [
+ 'floral', 'flower', 'botanical', 'bird', 'tropical', 'leaf', 'leaves',
+ 'fauna', 'flora', 'butterfly', 'dragonfly', 'peacock', 'palm', 'fern',
+ 'above and below', 'marine', 'seascape',
+];
+
+function hasSettlementRisk(row) {
+ const text = [row.pattern_name, row.design, row.features, row.color_name, row.collection]
+ .filter(Boolean).join(' ').toLowerCase();
+ return SETTLEMENT_KEYWORDS.some(kw => text.includes(kw));
+}
+
+async function main() {
+ const pool = new pg.Pool({ host: '/tmp', database: 'dw_unified' });
+
+ let allRows;
+ try {
+ const result = await pool.query(`
+ SELECT
+ id, dw_sku, mfr_sku, pattern_name, color_name, collection,
+ price_trade, price_retail, image_url, product_url,
+ width, material, discontinued, on_shopify, shopify_product_id,
+ image_rejected, image_rejection_reason, design, features
+ FROM harlequin_catalog
+ ORDER BY id ASC
+ `);
+ allRows = result.rows;
+ } catch (err) {
+ console.error('DB error:', err.message);
+ await pool.end();
+ process.exit(1);
+ }
+
+ await pool.end();
+
+ // Already rolled out (on_shopify = true)
+ const alreadyOnShopify = allRows.filter(r => r.on_shopify === true);
+
+ // Classify each row
+ const ready = [];
+ const skipped = [];
+
+ for (const row of allRows) {
+ if (row.on_shopify) {
+ skipped.push({ ...row, skip_reason: 'already-on-shopify' });
+ continue;
+ }
+ const missing = [];
+ if (!row.dw_sku) missing.push('dw_sku');
+ if (!row.price_trade) missing.push('price_trade');
+ if (!row.image_url) missing.push('image_url');
+ if (row.discontinued) missing.push('discontinued');
+ if (row.image_rejected) missing.push('image_rejected');
+
+ if (missing.length) {
+ skipped.push({ ...row, skip_reason: missing.join(',') });
+ } else {
+ ready.push({
+ ...row,
+ retail_price: computeRetailPrice(row.price_trade),
+ settlement_risk: hasSettlementRisk(row),
+ });
+ }
+ }
+
+ const settlementFlagged = ready.filter(r => r.settlement_risk);
+
+ // Restore-map count (already created this run)
+ let restoreMaps = 0;
+ try {
+ restoreMaps = fs.readdirSync(RESTORE_DIR).filter(f => f.endsWith('.json') && !f.includes('.rolled-back')).length;
+ } catch {}
+
+ // Skip reason breakdown
+ const skipReasons = {};
+ skipped.forEach(r => {
+ const reasons = r.skip_reason.split(',');
+ reasons.forEach(reason => { skipReasons[reason] = (skipReasons[reason] || 0) + 1; });
+ });
+
+ if (IS_JSON) {
+ console.log(JSON.stringify({
+ generated: new Date().toISOString(),
+ total: allRows.length,
+ ready: ready.length,
+ skipped: skipped.length,
+ already_on_shopify: alreadyOnShopify.length,
+ settlement_flagged: settlementFlagged.length,
+ restore_maps: restoreMaps,
+ skip_reasons: skipReasons,
+ ready_rows: IS_VERBOSE ? ready : ready.slice(0, 20),
+ skip_rows: IS_VERBOSE ? skipped : skipped.slice(0, 10),
+ }, null, 2));
+ return;
+ }
+
+ // Human-readable output
+ console.log('\n=== DWHA Harlequin Coverage Check ===');
+ console.log(`Generated: ${new Date().toLocaleString()}`);
+ console.log(`Total rows in DB: ${allRows.length}`);
+ console.log(`─────────────────────────────────────`);
+ console.log(`READY to onboard: ${ready.length} (have dw_sku + price_trade + image_url, not discontinued, not rejected)`);
+ console.log(`Already on Shopify: ${alreadyOnShopify.length}`);
+ console.log(`Skipped (issues): ${skipped.length - alreadyOnShopify.length}`);
+ console.log(`─────────────────────────────────────`);
+ console.log(`Settlement-flagged: ${settlementFlagged.length} (ready but require settlement gate before ACTIVE publish)`);
+ console.log(`Clean (no risk): ${ready.length - settlementFlagged.length}`);
+ console.log(`─────────────────────────────────────`);
+ console.log(`Restore-maps today: ${restoreMaps} (DRAFT products created this session)`);
+
+ console.log('\nSkip reason breakdown:');
+ Object.entries(skipReasons).sort((a,b) => b[1]-a[1]).forEach(([reason, count]) => {
+ console.log(` ${reason.padEnd(22)} ${count}`);
+ });
+
+ console.log('\nPrice range (retail):');
+ const prices = ready.map(r => r.retail_price).sort((a, b) => a - b);
+ if (prices.length) {
+ console.log(` Min: $${prices[0]} Max: $${prices[prices.length - 1]} Avg: $${(prices.reduce((s,p)=>s+p,0)/prices.length).toFixed(2)}`);
+ }
+
+ console.log('\nCollections in ready set:');
+ const colls = {};
+ ready.forEach(r => { if (r.collection) colls[r.collection] = (colls[r.collection]||0) + 1; });
+ Object.entries(colls).sort((a,b)=>b[1]-a[1]).slice(0, 10).forEach(([c, n]) => {
+ console.log(` ${c.padEnd(30)} ${n}`);
+ });
+
+ if (IS_VERBOSE) {
+ console.log('\n--- READY rows (first 50) ---');
+ ready.slice(0, 50).forEach((r, i) => {
+ const colorDisplay = r.color_name && !r.color_name.toLowerCase().includes('curation')
+ ? r.color_name : r.mfr_sku;
+ console.log(` ${String(i+1).padStart(3)}. ${r.dw_sku} "${r.pattern_name} ${colorDisplay}" trade:$${r.price_trade} retail:$${r.retail_price}${r.settlement_risk ? ' [SETTLEMENT-RISK]' : ''}`);
+ });
+ if (ready.length > 50) console.log(` ... and ${ready.length - 50} more`);
+
+ console.log('\n--- Settlement-risk rows ---');
+ settlementFlagged.slice(0, 20).forEach(r => {
+ console.log(` ${r.dw_sku} "${r.pattern_name}" — will need settlement gate before ACTIVE`);
+ });
+ }
+
+ console.log('\n=== Cadence plan ===');
+ console.log(` 25/day × ${Math.ceil(ready.length/25)} days = ~${Math.ceil(ready.length/25)} days to fully onboard`);
+ console.log(` All created as DRAFT — active publish is a separate Steve-gated step.`);
+ console.log('');
+}
+
+main().catch(err => { console.error('Fatal:', err); process.exit(1); });
diff --git a/scripts/onboard-batch.mjs b/scripts/onboard-batch.mjs
new file mode 100644
index 0000000..2de71df
--- /dev/null
+++ b/scripts/onboard-batch.mjs
@@ -0,0 +1,472 @@
+#!/usr/bin/env node
+/**
+ * DWHA Harlequin Onboard Batch — onboard-batch.mjs
+ * TK-10882 — VP DW Commerce, 2026-08-28
+ *
+ * Reads harlequin_catalog viable rows → downloads images locally →
+ * creates DRAFT Shopify products (never ACTIVE) → saves restore-maps.
+ *
+ * Hard rails:
+ * - All products created as status: 'draft' (ACTIVE publish is gated/Steve)
+ * - 25/day cadence cap enforced via data/cadence-counter.json
+ * - Images downloaded to images/ before Shopify attach (no remote CDN direct)
+ * - Settlement gate required before any floral/bird/tropical goes ACTIVE
+ * - Every created product is logged in restore-maps/<dw_sku>.json (reversible)
+ *
+ * Usage:
+ * node scripts/onboard-batch.mjs # live run (max 25 today)
+ * node scripts/onboard-batch.mjs --dry-run # dry run, prints would-do plan
+ * node scripts/onboard-batch.mjs --limit 5 # override day cap (for testing)
+ */
+
+import pg from 'pg';
+import fs from 'fs';
+import path from 'path';
+import https from 'https';
+import http from 'http';
+import { fileURLToPath } from 'url';
+import { execSync } from 'child_process';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = path.resolve(__dirname, '..');
+
+// ─── Config ───────────────────────────────────────────────────────────────────
+const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+ try {
+ const env = fs.readFileSync(path.resolve('/Users/macstudio3/Projects/secrets-manager/.env'), 'utf8');
+ const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+ return m ? m[1].trim() : null;
+ } catch { return null; }
+})();
+
+const IMAGES_DIR = path.join(PROJECT_ROOT, 'images');
+const RESTORE_DIR = path.join(PROJECT_ROOT, 'restore-maps');
+const DATA_DIR = path.join(PROJECT_ROOT, 'data');
+const CADENCE_FILE = path.join(DATA_DIR, 'cadence-counter.json');
+const LEDGER_FILE = path.join(DATA_DIR, 'onboard-ledger.jsonl');
+
+const DAY_LIMIT = 25; // Steve-approved cadence
+const SHOPIFY_API = `https://${SHOPIFY_STORE}/admin/api/2024-10`;
+const SHOPIFY_DELAY = 500; // ms between API calls (rate-limit safe)
+
+const IS_DRY_RUN = process.argv.includes('--dry-run');
+const LIMIT_ARG = (() => {
+ const i = process.argv.indexOf('--limit');
+ return i >= 0 ? parseInt(process.argv[i + 1], 10) : null;
+})();
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+function ensureDirs() {
+ [IMAGES_DIR, RESTORE_DIR, DATA_DIR].forEach(d => fs.mkdirSync(d, { recursive: true }));
+}
+
+function today() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+function getCadenceState() {
+ try {
+ const s = JSON.parse(fs.readFileSync(CADENCE_FILE, 'utf8'));
+ if (s.date !== today()) return { date: today(), count: 0 };
+ return s;
+ } catch {
+ return { date: today(), count: 0 };
+ }
+}
+
+function saveCadenceState(state) {
+ fs.writeFileSync(CADENCE_FILE, JSON.stringify(state, null, 2));
+}
+
+function appendLedger(entry) {
+ fs.appendFileSync(LEDGER_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+}
+
+function sleep(ms) {
+ return new Promise(r => setTimeout(r, ms));
+}
+
+// ─── Image download (with cache-check + retry) ────────────────────────────────
+async function downloadImage(url, dw_sku, attempt = 1) {
+ const ext = url.split('?')[0].split('.').pop().toLowerCase() || 'jpg';
+ const safeSku = dw_sku.replace(/[^A-Za-z0-9\-]/g, '_');
+ const localPath = path.join(IMAGES_DIR, `${safeSku}.${ext}`);
+
+ // Cache-check: skip if already downloaded and >0 bytes
+ if (fs.existsSync(localPath) && fs.statSync(localPath).size > 1000) {
+ return { localPath, cached: true };
+ }
+
+ return new Promise((resolve, reject) => {
+ const proto = url.startsWith('https') ? https : http;
+ const file = fs.createWriteStream(localPath);
+ const req = proto.get(url, { headers: { 'User-Agent': 'DW-Harlequin-Onboard/1.0' } }, res => {
+ if (res.statusCode === 301 || res.statusCode === 302) {
+ file.close();
+ fs.unlinkSync(localPath);
+ if (attempt <= 3) {
+ downloadImage(res.headers.location, dw_sku, attempt + 1).then(resolve).catch(reject);
+ } else {
+ reject(new Error(`Too many redirects for ${url}`));
+ }
+ return;
+ }
+ if (res.statusCode !== 200) {
+ file.close();
+ try { fs.unlinkSync(localPath); } catch {}
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+ return;
+ }
+ res.pipe(file);
+ file.on('finish', () => file.close(() => resolve({ localPath, cached: false })));
+ });
+ req.on('error', err => {
+ try { fs.unlinkSync(localPath); } catch {}
+ if (attempt <= 3) {
+ setTimeout(() => downloadImage(url, dw_sku, attempt + 1).then(resolve).catch(reject), 1000 * attempt);
+ } else {
+ reject(err);
+ }
+ });
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error(`Timeout downloading ${url}`)); });
+ });
+}
+
+// ─── Price computation ────────────────────────────────────────────────────────
+function computeRetailPrice(price_trade) {
+ // trade price is already our "cost" from Harlequin
+ // retail = cost / 0.65 / 0.85, rounded to 2 decimals
+ const raw = parseFloat(price_trade) / 0.65 / 0.85;
+ return Math.round(raw * 100) / 100;
+}
+
+// ─── Build Shopify product payload ───────────────────────────────────────────
+function buildProductPayload(row) {
+ const {
+ dw_sku, pattern_name, color_name, collection,
+ price_trade, width, length, repeat_v, repeat_h,
+ material, product_type, mfr_sku,
+ fire_rating, finish, application, match_type, design, features
+ } = row;
+
+ const colorDisplay = color_name && !color_name.toLowerCase().includes('curation')
+ ? color_name
+ : mfr_sku; // fall back to mfr_sku per DW rules (never "Unknown")
+
+ const title = `${pattern_name} ${colorDisplay} | Harlequin`;
+
+ // Body HTML — spec fields
+ const specLines = [
+ width ? `<li><strong>Width:</strong> ${width}</li>` : '',
+ length ? `<li><strong>Length:</strong> ${length}</li>` : '',
+ repeat_v ? `<li><strong>Vertical Repeat:</strong> ${repeat_v}</li>` : '',
+ repeat_h ? `<li><strong>Horizontal Repeat:</strong> ${repeat_h}</li>` : '',
+ material ? `<li><strong>Material:</strong> ${material}</li>` : '',
+ match_type ? `<li><strong>Match Type:</strong> ${match_type}</li>` : '',
+ fire_rating ? `<li><strong>Fire Rating:</strong> ${fire_rating}</li>` : '',
+ finish ? `<li><strong>Finish:</strong> ${finish}</li>` : '',
+ application ? `<li><strong>Application:</strong> ${application}</li>` : '',
+ design ? `<li><strong>Design:</strong> ${design}</li>` : '',
+ mfr_sku ? `<li><strong>MFR SKU:</strong> ${mfr_sku}</li>` : '',
+ dw_sku ? `<li><strong>DW SKU:</strong> ${dw_sku}</li>` : '',
+ ].filter(Boolean).join('\n');
+
+ const body_html = [
+ features ? `<p>${features}</p>` : '',
+ specLines ? `<ul>\n${specLines}\n</ul>` : '',
+ ].filter(Boolean).join('\n');
+
+ const retailPrice = computeRetailPrice(price_trade);
+
+ const tags = [
+ 'Harlequin',
+ 'DWHA',
+ collection ? `Collection-${collection.replace(/\s+/g, '-')}` : '',
+ product_type ? product_type : '',
+ 'Needs-Width', // mandatory until width metafield confirmed
+ 'harlequin-onboard-2026',
+ ].filter(Boolean).join(', ');
+
+ return {
+ product: {
+ title,
+ vendor: 'Harlequin',
+ product_type: 'Wallcovering',
+ status: 'draft', // HARD RAIL — never 'active' here
+ tags,
+ body_html,
+ variants: [
+ {
+ title: 'Single Roll',
+ sku: dw_sku,
+ price: String(retailPrice),
+ inventory_management: null, // no tracking
+ fulfillment_service: 'manual',
+ },
+ {
+ title: 'Sample',
+ sku: `${dw_sku}-Sample`,
+ price: '4.25',
+ inventory_management: null,
+ fulfillment_service: 'manual',
+ },
+ ],
+ },
+ };
+}
+
+// ─── Shopify REST calls ───────────────────────────────────────────────────────
+async function shopifyPost(endpoint, payload) {
+ const url = `${SHOPIFY_API}${endpoint}`;
+ const body = JSON.stringify(payload);
+
+ return new Promise((resolve, reject) => {
+ const opts = new URL(url);
+ const reqOpts = {
+ hostname: opts.hostname,
+ path: opts.pathname + opts.search,
+ method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': SHOPIFY_TOKEN,
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(body),
+ },
+ };
+ const req = https.request(reqOpts, res => {
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', () => {
+ if (res.statusCode >= 400) {
+ reject(new Error(`Shopify ${res.statusCode}: ${data.slice(0, 300)}`));
+ } else {
+ resolve(JSON.parse(data));
+ }
+ });
+ });
+ req.on('error', reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+async function shopifyPostImage(productId, localPath, dw_sku) {
+ const imageData = fs.readFileSync(localPath);
+ const b64 = imageData.toString('base64');
+ const ext = path.extname(localPath).slice(1) || 'jpg';
+ const payload = {
+ image: {
+ attachment: b64,
+ filename: `${dw_sku}.${ext}`,
+ },
+ };
+ return shopifyPost(`/products/${productId}/images.json`, payload);
+}
+
+// ─── Restore-map ──────────────────────────────────────────────────────────────
+function saveRestoreMap(dw_sku, productId, row) {
+ const mapPath = path.join(RESTORE_DIR, `${dw_sku}.json`);
+ const map = {
+ ts: new Date().toISOString(),
+ dw_sku,
+ shopify_product_id: productId,
+ action: 'created-draft',
+ undo_cmd: `node scripts/rollback.mjs ${dw_sku}`,
+ db_row_id: row.id,
+ row_snapshot: row,
+ };
+ fs.writeFileSync(mapPath, JSON.stringify(map, null, 2));
+ return mapPath;
+}
+
+// ─── DB update: mark on_shopify = true ───────────────────────────────────────
+async function markOnShopify(pool, id, shopify_product_id) {
+ await pool.query(
+ 'UPDATE harlequin_catalog SET on_shopify = true, shopify_product_id = $1, updated_at = NOW() WHERE id = $2',
+ [shopify_product_id, id]
+ );
+}
+
+// ─── Main ─────────────────────────────────────────────────────────────────────
+async function main() {
+ ensureDirs();
+
+ if (!SHOPIFY_TOKEN) {
+ console.error('FATAL: SHOPIFY_ADMIN_TOKEN not found. Aborting.');
+ process.exit(1);
+ }
+
+ // Cadence check
+ const cadence = getCadenceState();
+ const effectiveLimit = LIMIT_ARG ?? DAY_LIMIT;
+ const remaining = effectiveLimit - cadence.count;
+
+ console.log(`\n=== DWHA Harlequin Onboard Batch ===`);
+ console.log(`Date: ${today()} | Cadence: ${cadence.count}/${effectiveLimit} used | Remaining today: ${remaining}`);
+ console.log(`Mode: ${IS_DRY_RUN ? 'DRY-RUN (no writes)' : 'LIVE'}`);
+
+ if (remaining <= 0 && !IS_DRY_RUN) {
+ console.log(`\nDay cadence cap reached (${effectiveLimit}/day). Run tomorrow or increase --limit.`);
+ process.exit(0);
+ }
+
+ // Query viable rows (not yet on Shopify)
+ const pool = new pg.Pool({ host: '/tmp', database: 'dw_unified' });
+
+ let rows;
+ try {
+ const result = await pool.query(`
+ SELECT
+ id, dw_sku, mfr_sku, pattern_name, color_name, collection,
+ product_type, width, length, repeat_v, repeat_h, material,
+ price_trade, price_retail, image_url, product_url,
+ fire_rating, finish, application, match_type, design, features,
+ color_primary, color_secondary
+ FROM harlequin_catalog
+ WHERE
+ price_trade IS NOT NULL
+ AND image_url IS NOT NULL
+ AND dw_sku IS NOT NULL
+ AND (discontinued IS FALSE OR discontinued IS NULL)
+ AND (image_rejected IS FALSE OR image_rejected IS NULL)
+ AND (on_shopify IS FALSE OR on_shopify IS NULL)
+ ORDER BY id ASC
+ LIMIT $1
+ `, [IS_DRY_RUN ? 9999 : remaining]);
+ rows = result.rows;
+ } catch (err) {
+ console.error('DB query failed:', err.message);
+ await pool.end();
+ process.exit(1);
+ }
+
+ console.log(`\nViable rows to process: ${rows.length}${IS_DRY_RUN ? ' (dry-run shows all)' : ` (capped at ${remaining} today)`}`);
+
+ if (IS_DRY_RUN) {
+ console.log('\n--- DRY-RUN PREVIEW (first 10 rows) ---');
+ rows.slice(0, 10).forEach((r, i) => {
+ const retail = computeRetailPrice(r.price_trade);
+ const colorDisplay = r.color_name && !r.color_name.toLowerCase().includes('curation')
+ ? r.color_name : r.mfr_sku;
+ console.log(` ${i + 1}. ${r.dw_sku} — "${r.pattern_name} ${colorDisplay} | Harlequin" trade:$${r.price_trade} retail:$${retail} img:${r.image_url ? 'yes' : 'NO'}`);
+ });
+ if (rows.length > 10) console.log(` ... and ${rows.length - 10} more`);
+ console.log(`\nDRY-RUN TOTAL: ${rows.length} rows ready to onboard.`);
+ await pool.end();
+ return;
+ }
+
+ // ── LIVE RUN ──
+ let created = 0, skipped = 0, errors = [];
+
+ for (const row of rows) {
+ const { dw_sku, image_url } = row;
+ console.log(`\n[${created + skipped + 1}/${rows.length}] Processing ${dw_sku} ...`);
+
+ // 1. Download image
+ let localImagePath;
+ try {
+ const { localPath, cached } = await downloadImage(image_url, dw_sku);
+ localImagePath = localPath;
+ console.log(` image: ${cached ? 'cached' : 'downloaded'} → ${path.basename(localPath)}`);
+ } catch (imgErr) {
+ console.warn(` SKIP — image download failed: ${imgErr.message}`);
+ errors.push({ dw_sku, stage: 'image-download', error: imgErr.message });
+ skipped++;
+ continue;
+ }
+
+ // 2. Build + create Shopify product (DRAFT)
+ let productId;
+ try {
+ const payload = buildProductPayload(row);
+ await sleep(SHOPIFY_DELAY);
+ const response = await shopifyPost('/products.json', payload);
+ productId = response.product?.id;
+ if (!productId) throw new Error('No product ID returned from Shopify');
+ console.log(` shopify: created DRAFT product ${productId}`);
+ } catch (shopErr) {
+ console.warn(` SKIP — Shopify create failed: ${shopErr.message}`);
+ errors.push({ dw_sku, stage: 'shopify-create', error: shopErr.message });
+ skipped++;
+ continue;
+ }
+
+ // 3. Attach image
+ try {
+ await sleep(SHOPIFY_DELAY);
+ await shopifyPostImage(productId, localImagePath, dw_sku);
+ console.log(` image: attached to product ${productId}`);
+ } catch (imgAttachErr) {
+ // Non-fatal — product is created, image can be added later; log it
+ console.warn(` WARN — image attach failed (product still created): ${imgAttachErr.message}`);
+ errors.push({ dw_sku, stage: 'image-attach', error: imgAttachErr.message, shopify_product_id: productId });
+ }
+
+ // 4. DB: mark on_shopify
+ try {
+ await markOnShopify(pool, row.id, String(productId));
+ console.log(` db: on_shopify=true, shopify_product_id=${productId}`);
+ } catch (dbErr) {
+ console.warn(` WARN — DB update failed: ${dbErr.message}`);
+ }
+
+ // 5. Save restore-map
+ const mapPath = saveRestoreMap(dw_sku, String(productId), row);
+ console.log(` restore-map: ${path.basename(mapPath)}`);
+
+ // 6. Ledger
+ appendLedger({
+ agent: 'vp-dw-commerce',
+ ticket: 'TK-10882',
+ action: 'created-draft-product',
+ dw_sku,
+ shopify_product_id: String(productId),
+ blast_radius: 1,
+ undo_cmd: `node scripts/rollback.mjs ${dw_sku}`,
+ verify: `shopify product get ${productId}`,
+ });
+
+ // 7. Also log to global executed-reversible ledger
+ try {
+ const execLedger = '/Users/macstudio3/.claude/yolo-queue/executed-reversible/ledger.jsonl';
+ fs.mkdirSync(path.dirname(execLedger), { recursive: true });
+ fs.appendFileSync(execLedger, JSON.stringify({
+ ts: new Date().toISOString(),
+ agent: 'vp-dw-commerce',
+ ticket: 'TK-10882',
+ action: `DWHA onboard: created DRAFT Shopify product for ${dw_sku}`,
+ blast_radius: 1,
+ undo_cmd: `node ~/Projects/dwha-harlequin-onboard/scripts/rollback.mjs ${dw_sku}`,
+ verify: `curl -s -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" https://${SHOPIFY_STORE}/admin/api/2024-10/products/${productId}.json | jq .product.status`,
+ }) + '\n');
+ } catch {}
+
+ created++;
+ cadence.count++;
+ saveCadenceState(cadence);
+
+ if (cadence.count >= effectiveLimit) {
+ console.log(`\nDay cap of ${effectiveLimit} reached. Stopping.`);
+ break;
+ }
+ }
+
+ await pool.end();
+
+ console.log(`\n=== Batch complete ===`);
+ console.log(` Created (DRAFT): ${created}`);
+ console.log(` Skipped: ${skipped}`);
+ console.log(` Errors: ${errors.length}`);
+ if (errors.length) {
+ console.log('\nError detail:');
+ errors.forEach(e => console.log(` ${e.dw_sku} [${e.stage}]: ${e.error}`));
+ }
+ console.log(`\nAll created as DRAFT — publish/activate is a separate Steve-gated step.`);
+ console.log(`Settlement gate required for floral/bird/tropical before any ACTIVE publish.`);
+}
+
+main().catch(err => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
diff --git a/scripts/rollback.mjs b/scripts/rollback.mjs
new file mode 100644
index 0000000..5d1f2b2
--- /dev/null
+++ b/scripts/rollback.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+/**
+ * DWHA Harlequin Rollback — rollback.mjs
+ * TK-10882 — VP DW Commerce
+ *
+ * Reads restore-map for one or all DWHA products and deletes the Shopify DRAFT
+ * products that were created by onboard-batch.mjs, then clears on_shopify in DB.
+ *
+ * Usage:
+ * node scripts/rollback.mjs DWHA-570559 # roll back one SKU
+ * node scripts/rollback.mjs --all # roll back all in restore-maps/
+ * node scripts/rollback.mjs --all --dry-run # preview only
+ */
+
+import pg from 'pg';
+import fs from 'fs';
+import path from 'path';
+import https from 'https';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = path.resolve(__dirname, '..');
+
+const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+ try {
+ const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
+ const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+ return m ? m[1].trim() : null;
+ } catch { return null; }
+})();
+
+const RESTORE_DIR = path.join(PROJECT_ROOT, 'restore-maps');
+const DATA_DIR = path.join(PROJECT_ROOT, 'data');
+const ROLLBACK_LOG = path.join(DATA_DIR, 'rollback-ledger.jsonl');
+const SHOPIFY_API = `https://${SHOPIFY_STORE}/admin/api/2024-10`;
+
+const IS_DRY_RUN = process.argv.includes('--dry-run');
+const IS_ALL = process.argv.includes('--all');
+const TARGET_SKU = process.argv.find(a => a.startsWith('DWHA-'));
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function appendLog(entry) {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ fs.appendFileSync(ROLLBACK_LOG, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+}
+
+async function shopifyDelete(productId) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(`${SHOPIFY_API}/products/${productId}.json`);
+ const req = https.request({
+ hostname: url.hostname,
+ path: url.pathname,
+ method: 'DELETE',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN },
+ }, res => {
+ let data = '';
+ res.on('data', c => data += c);
+ res.on('end', () => {
+ if (res.statusCode === 200 || res.statusCode === 204) {
+ resolve(true);
+ } else if (res.statusCode === 404) {
+ resolve('not-found'); // already gone — treat as success
+ } else {
+ reject(new Error(`Shopify DELETE ${res.statusCode}: ${data.slice(0, 200)}`));
+ }
+ });
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+async function rollbackOne(mapPath, pool) {
+ const map = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
+ const { dw_sku, shopify_product_id, db_row_id } = map;
+
+ console.log(` Rolling back ${dw_sku} (Shopify: ${shopify_product_id}) ...`);
+
+ if (IS_DRY_RUN) {
+ console.log(` DRY-RUN: would DELETE /products/${shopify_product_id}.json`);
+ console.log(` DRY-RUN: would UPDATE harlequin_catalog SET on_shopify=false WHERE id=${db_row_id}`);
+ return;
+ }
+
+ // 1. Delete Shopify product
+ let shopifyResult;
+ try {
+ await sleep(300);
+ shopifyResult = await shopifyDelete(shopify_product_id);
+ console.log(` shopify: ${shopifyResult === 'not-found' ? 'already deleted (404)' : 'deleted'}`);
+ } catch (err) {
+ console.error(` ERROR deleting Shopify product: ${err.message}`);
+ appendLog({ action: 'rollback-failed', dw_sku, shopify_product_id, stage: 'shopify-delete', error: err.message });
+ return;
+ }
+
+ // 2. Clear on_shopify in DB
+ try {
+ await pool.query(
+ 'UPDATE harlequin_catalog SET on_shopify = false, shopify_product_id = NULL, updated_at = NOW() WHERE id = $1',
+ [db_row_id]
+ );
+ console.log(` db: on_shopify=false cleared`);
+ } catch (dbErr) {
+ console.warn(` WARN: DB rollback failed — ${dbErr.message}. Shopify product was deleted; fix DB manually.`);
+ }
+
+ // 3. Archive the restore-map (rename so it's not re-used)
+ const donePath = mapPath.replace('.json', '.rolled-back.json');
+ fs.renameSync(mapPath, donePath);
+ console.log(` restore-map archived: ${path.basename(donePath)}`);
+
+ appendLog({ action: 'rolled-back', dw_sku, shopify_product_id, db_row_id, ticket: 'TK-10882' });
+}
+
+async function main() {
+ if (!SHOPIFY_TOKEN) {
+ console.error('FATAL: SHOPIFY_ADMIN_TOKEN not found.');
+ process.exit(1);
+ }
+
+ let mapPaths = [];
+
+ if (IS_ALL) {
+ if (!fs.existsSync(RESTORE_DIR)) {
+ console.log('No restore-maps directory found. Nothing to roll back.');
+ process.exit(0);
+ }
+ mapPaths = fs.readdirSync(RESTORE_DIR)
+ .filter(f => f.endsWith('.json') && !f.includes('.rolled-back'))
+ .map(f => path.join(RESTORE_DIR, f));
+ } else if (TARGET_SKU) {
+ const mapPath = path.join(RESTORE_DIR, `${TARGET_SKU}.json`);
+ if (!fs.existsSync(mapPath)) {
+ console.error(`No restore-map found for ${TARGET_SKU} at ${mapPath}`);
+ process.exit(1);
+ }
+ mapPaths = [mapPath];
+ } else {
+ console.error('Usage: rollback.mjs <DWHA-XXXXXX> | --all [--dry-run]');
+ process.exit(1);
+ }
+
+ console.log(`\n=== DWHA Rollback ===`);
+ console.log(`Mode: ${IS_DRY_RUN ? 'DRY-RUN' : 'LIVE DESTRUCTIVE'} | Maps: ${mapPaths.length}`);
+ if (!IS_DRY_RUN) console.log('WARNING: This permanently deletes Shopify DRAFT products.');
+
+ const pool = new pg.Pool({ host: '/tmp', database: 'dw_unified' });
+
+ for (const mapPath of mapPaths) {
+ await rollbackOne(mapPath, pool);
+ }
+
+ await pool.end();
+ console.log('\nRollback complete.');
+}
+
+main().catch(err => { console.error('Fatal:', err); process.exit(1); });
(oldest)
·
back to Dwha Harlequin Onboard
·
auto-data-snapshot: 2026-08-31T11:17:15 (3 data files) — .gi 9e7a440 →