← back to Yolo Agent

orphan-audit.js

261 lines

#!/usr/bin/env node
const path = require('path');
try { require('dotenv').config({ path: path.join(__dirname, '.env') }); } catch (_) {}
/**
 * Orphan Product Audit — Finds active products not in any collection
 * READ-ONLY: Does not modify any data
 */

const https = require('https');
const fs = require('fs');

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ORDERS_TOKEN;
const API_VERSION = '2024-01';
const DELAY_MS = 500;
if (!TOKEN) {
  console.error('Missing SHOPIFY_ORDERS_TOKEN.');
  process.exit(1);
}

function graphql(query, variables = {}) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({ query, variables });
    const options = {
      hostname: SHOP,
      path: `/admin/api/${API_VERSION}/graphql.json`,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': TOKEN,
        'Content-Length': Buffer.byteLength(body),
      },
    };
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => (data += chunk));
      res.on('end', () => {
        try {
          resolve(JSON.parse(data));
        } catch (e) {
          reject(new Error(`JSON parse error: ${data.substring(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function fetchAllActiveProducts() {
  const allProducts = [];
  let cursor = null;
  let page = 0;

  while (true) {
    page++;
    const afterClause = cursor ? `, after: "${cursor}"` : '';
    const query = `{
      products(first: 250, query: "status:active"${afterClause}) {
        edges {
          node {
            id
            title
            vendor
            handle
            collections(first: 10) {
              edges {
                node {
                  id
                  title
                }
              }
            }
          }
          cursor
        }
        pageInfo {
          hasNextPage
        }
      }
    }`;

    const result = await graphql(query);

    if (result.errors) {
      console.error(`Page ${page} errors:`, JSON.stringify(result.errors));
      break;
    }

    const edges = result.data?.products?.edges || [];
    if (edges.length === 0) break;

    for (const edge of edges) {
      const p = edge.node;
      allProducts.push({
        id: p.id,
        title: p.title,
        vendor: p.vendor,
        handle: p.handle,
        collectionCount: p.collections.edges.length,
        collections: p.collections.edges.map((c) => c.node.title),
      });
    }

    cursor = edges[edges.length - 1].cursor;
    const hasNext = result.data.products.pageInfo.hasNextPage;
    const cost = result.extensions?.cost;

    console.log(
      `Page ${page}: ${edges.length} products (total: ${allProducts.length}) | ` +
        `Cost: ${cost?.actualQueryCost || '?'}/${cost?.throttleStatus?.currentlyAvailable || '?'} available`
    );

    if (!hasNext) break;
    await sleep(DELAY_MS);
  }

  return allProducts;
}

async function run() {
  console.log('=== Orphan Product Audit ===');
  console.log(`Started: ${new Date().toISOString()}\n`);

  // Fetch all active products with their collections
  const products = await fetchAllActiveProducts();
  console.log(`\nTotal active products fetched: ${products.length}`);

  // Find orphans (products with no collections)
  const orphans = products.filter((p) => p.collectionCount === 0);
  console.log(`Orphaned products (no collection): ${orphans.length}`);

  // Group orphans by vendor
  const vendorMap = {};
  for (const p of orphans) {
    const vendor = p.vendor || '(no vendor)';
    if (!vendorMap[vendor]) {
      vendorMap[vendor] = { count: 0, examples: [] };
    }
    vendorMap[vendor].count++;
    if (vendorMap[vendor].examples.length < 5) {
      vendorMap[vendor].examples.push({
        title: p.title,
        handle: p.handle,
      });
    }
  }

  // Sort by count descending
  const orphansByVendor = Object.entries(vendorMap)
    .map(([vendor, data]) => ({
      vendor,
      count: data.count,
      examples: data.examples,
    }))
    .sort((a, b) => b.count - a.count);

  // Also compute vendor totals (all products, not just orphans)
  const vendorTotals = {};
  for (const p of products) {
    const v = p.vendor || '(no vendor)';
    vendorTotals[v] = (vendorTotals[v] || 0) + 1;
  }

  // Enrich orphansByVendor with total product count for that vendor
  for (const entry of orphansByVendor) {
    entry.totalActiveForVendor = vendorTotals[entry.vendor] || entry.count;
    entry.percentOrphaned =
      ((entry.count / entry.totalActiveForVendor) * 100).toFixed(1) + '%';
  }

  const percentOrphaned = ((orphans.length / products.length) * 100).toFixed(2) + '%';

  const report = {
    timestamp: new Date().toISOString(),
    totalActiveProducts: products.length,
    totalOrphans: orphans.length,
    percentOrphaned,
    vendorsWithOrphans: orphansByVendor.length,
    totalVendors: Object.keys(vendorTotals).length,
    orphansByVendor,
  };

  // Save JSON report
  const reportPath = path.join(__dirname, 'logs', 'orphan-product-report.json');
  fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
  console.log(`\nJSON report saved: ${reportPath}`);

  // Generate markdown summary
  const top20 = orphansByVendor.slice(0, 20);
  let md = `# Orphan Product Audit Report\n\n`;
  md += `**Date**: ${new Date().toISOString()}\n\n`;
  md += `## Summary\n\n`;
  md += `| Metric | Value |\n|--------|-------|\n`;
  md += `| Total Active Products | ${products.length.toLocaleString()} |\n`;
  md += `| Products with NO Collection | ${orphans.length.toLocaleString()} |\n`;
  md += `| Percent Orphaned | ${percentOrphaned} |\n`;
  md += `| Vendors with Orphans | ${orphansByVendor.length} |\n`;
  md += `| Total Vendors | ${Object.keys(vendorTotals).length} |\n\n`;
  md += `## Top ${top20.length} Vendors with Most Orphans\n\n`;
  md += `| # | Vendor | Orphans | Total Active | % Orphaned |\n`;
  md += `|---|--------|---------|--------------|------------|\n`;
  top20.forEach((v, i) => {
    md += `| ${i + 1} | ${v.vendor} | ${v.count} | ${v.totalActiveForVendor} | ${v.percentOrphaned} |\n`;
  });
  md += `\n## Example Orphaned Products (Top 5 Vendors)\n\n`;
  top20.slice(0, 5).forEach((v) => {
    md += `### ${v.vendor} (${v.count} orphans)\n`;
    v.examples.forEach((ex) => {
      md += `- ${ex.title} (handle: \`${ex.handle}\`)\n`;
    });
    md += `\n`;
  });

  // All vendors list
  if (orphansByVendor.length > 20) {
    md += `## All Vendors with Orphans\n\n`;
    md += `| Vendor | Orphans | % of Vendor |\n|--------|---------|-------------|\n`;
    orphansByVendor.forEach((v) => {
      md += `| ${v.vendor} | ${v.count} | ${v.percentOrphaned} |\n`;
    });
    md += `\n`;
  }

  const summaryPath = path.join(__dirname, 'logs', 'orphan-product-summary.md');
  fs.writeFileSync(summaryPath, md);
  console.log(`Summary saved: ${summaryPath}`);

  // Print quick summary
  console.log(`\n=== RESULTS ===`);
  console.log(`Active products: ${products.length}`);
  console.log(`Orphans: ${orphans.length} (${percentOrphaned})`);
  console.log(`Vendors with orphans: ${orphansByVendor.length}`);
  console.log(`\nTop 10 vendors:`);
  top20.slice(0, 10).forEach((v, i) => {
    console.log(`  ${i + 1}. ${v.vendor}: ${v.count} orphans (${v.percentOrphaned} of ${v.totalActiveForVendor})`);
  });

  // Return summary for Slack
  return {
    totalActive: products.length,
    totalOrphans: orphans.length,
    percentOrphaned,
    vendorsWithOrphans: orphansByVendor.length,
    top5: top20.slice(0, 5).map((v) => `${v.vendor}: ${v.count}`),
  };
}

run()
  .then((summary) => {
    console.log('\n=== SLACK PAYLOAD ===');
    console.log(JSON.stringify(summary));
  })
  .catch((err) => {
    console.error('FATAL ERROR:', err);
    process.exit(1);
  });