← back to Dwha Harlequin Onboard
scripts/rollback.mjs
161 lines
#!/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); });