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