← back to Dw Signup Fulfillment

lib/shopify.js

189 lines

'use strict';
// Thin Shopify Admin API client. Reads SHOPIFY_FULFILLMENT_TOKEN from config.
//
// SAFETY: when config.DRY_RUN is true, every WRITE verb (POST/PUT/DELETE) is
// short-circuited — the intended method, URL and payload are logged and a
// synthetic ok response is returned. No live call is made. Only GET is allowed
// to hit the network in DRY_RUN (reads are harmless), and even that requires a
// real token — otherwise it too is stubbed.
const config = require('./config');

const WRITE_VERBS = new Set(['POST', 'PUT', 'DELETE']);

function base() {
  return `https://${config.SHOP_DOMAIN}/admin/api/${config.SHOPIFY_API_VERSION}`;
}

function log(...a) { console.log('[shopify]', ...a); }

// Core request. path is relative to /admin/api/<ver> (e.g. '/gift_cards.json').
async function request(method, path, body) {
  const url = base() + path;
  const isWrite = WRITE_VERBS.has(method.toUpperCase());

  if (config.DRY_RUN && isWrite) {
    log(`DRY_RUN — WOULD ${method} ${url}`);
    if (body !== undefined) log('DRY_RUN — payload:', JSON.stringify(body, null, 2));
    return { ok: true, dryRun: true, method, url, body, status: 0, json: synthetic(path, body) };
  }

  if (!config.SHOPIFY_FULFILLMENT_TOKEN) {
    // No token yet (pre go-live). Never throw — return a clearly-marked stub so
    // callers and the selftest keep working. This is the expected state today.
    log(`NO TOKEN — WOULD ${method} ${url} (SHOPIFY_FULFILLMENT_TOKEN unset)`);
    if (body !== undefined) log('NO TOKEN — payload:', JSON.stringify(body, null, 2));
    return { ok: true, dryRun: true, noToken: true, method, url, body, status: 0, json: synthetic(path, body) };
  }

  const res = await fetch(url, {
    method,
    headers: {
      'X-Shopify-Access-Token': config.SHOPIFY_FULFILLMENT_TOKEN,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  let json = null;
  try { json = await res.json(); } catch { json = null; }
  return { ok: res.ok, status: res.status, method, url, json };
}

// Build a plausible synthetic response so dry-run callers can read back an id/code.
function synthetic(path, body) {
  const randId = (base) => base + Math.floor(Math.random() * 1e6);
  if (path.startsWith('/gift_cards')) {
    const code = 'DRYRUN' + Math.random().toString(36).slice(2, 10).toUpperCase();
    return { gift_card: { id: randId(900000000), code, masked_code: '••••' + code.slice(-4), balance: body?.gift_card?.initial_value, initial_value: body?.gift_card?.initial_value } };
  }
  if (path.startsWith('/price_rules') && !path.includes('discount_codes')) {
    return { price_rule: { id: randId(800000000) } };
  }
  if (path.includes('discount_codes')) {
    const code = body?.discount_code?.code || ('DRYRUN' + Math.random().toString(36).slice(2, 8).toUpperCase());
    return { discount_code: { id: randId(700000000), code } };
  }
  if (path.startsWith('/customers') && body?.customer) {
    return { customer: { id: body.customer.id || randId(600000000), ...body.customer } };
  }
  if (path.startsWith('/webhooks')) {
    return { webhook: { id: randId(500000000), ...(body?.webhook || {}) } };
  }
  return {};
}

// ---- High-level helpers ----

// Create a Shopify gift card with a fixed initial_value.
async function createGiftCard({ value, note, currency }) {
  return request('POST', '/gift_cards.json', {
    gift_card: { initial_value: String(value), note: note || 'DW retail free-samples', currency: currency || config.CURRENCY },
  });
}

// Add tags to a customer (tagsAdd equivalent — REST needs the merged tag string,
// so the caller passes the already-merged value; addTags() merges for you).
async function updateCustomer(customerId, fields) {
  return request('PUT', `/customers/${customerId}.json`, { customer: { id: customerId, ...fields } });
}

// Read a customer's current tags so we can append rather than clobber.
async function getCustomer(customerId) {
  return request('GET', `/customers/${customerId}.json`, undefined);
}

// Resolve a Shopify customer id from an email (read_customers scope). Used at trade
// approval when the application came from the public form (no shopify_customer_id).
// Returns the id, or null if no customer with that email exists / can't be read.
async function findCustomerByEmail(emailAddr) {
  const e = (emailAddr || '').trim().toLowerCase();
  if (!e) return null;
  const r = await request('GET', `/customers/search.json?query=${encodeURIComponent('email:' + e)}`, undefined);
  const c = r && r.json && Array.isArray(r.json.customers) ? r.json.customers[0] : null;
  return c && c.id ? c.id : null;
}

// Append a tag to a customer without dropping existing tags.
async function addTags(customerId, newTags) {
  const wanted = (Array.isArray(newTags) ? newTags : [newTags]).map(t => t.trim()).filter(Boolean);
  let existing = [];
  const cur = await getCustomer(customerId);
  if (cur?.json?.customer?.tags) existing = cur.json.customer.tags.split(',').map(t => t.trim()).filter(Boolean);
  const merged = Array.from(new Set([...existing, ...wanted])).join(', ');
  return updateCustomer(customerId, { tags: merged });
}

// Read one customer metafield value (namespace.key), or null. Used for the
// welcome-gift idempotency flag. GET → allowed in DRY_RUN (with a real token).
async function getCustomerMetafield(customerId, namespace, key) {
  const r = await request('GET', `/customers/${customerId}/metafields.json?namespace=${encodeURIComponent(namespace)}`, undefined);
  const mfs = r && r.json && Array.isArray(r.json.metafields) ? r.json.metafields : [];
  const m = mfs.find(x => x.key === key && x.namespace === namespace);
  return m ? m.value : null;
}

// Set a customer metafield (e.g. custom.assigned_rep).
async function setCustomerMetafield(customerId, { namespace, key, value, type }) {
  return request('POST', `/customers/${customerId}/metafields.json`, {
    metafield: { namespace, key, value: String(value), type: type || 'single_line_text_field' },
  });
}

// Register a webhook (used at go-live, from DEPLOY.md).
async function createWebhook({ topic, address, format }) {
  return request('POST', '/webhooks.json', { webhook: { topic, address, format: format || 'json' } });
}

// ---- GraphQL Admin API ----
//
// A GraphQL POST to /graphql.json. A GraphQL *mutation* is a WRITE, so in DRY_RUN
// (or with no token) we short-circuit it exactly like a REST write: log the WOULD-
// call + the query/variables and return a synthetic ok envelope so callers can read
// back a plausible id. A GraphQL *query* (read) is allowed to hit the network in
// DRY_RUN, but only if a real token exists — otherwise it too is stubbed.
//
//   graphql(query, variables, { synthetic })
//     - `synthetic(query, variables)` (optional) builds the fake `data` object
//       returned in DRY_RUN so downstream code sees a realistic shape.
function isMutation(query) {
  return /^\s*mutation\b/m.test(query || '') || /(^|\})\s*mutation\b/.test(query || '');
}

async function graphql(query, variables, opts = {}) {
  const url = `${base()}/graphql.json`;
  const write = isMutation(query);
  const syn = typeof opts.synthetic === 'function' ? opts.synthetic(query, variables) : {};

  if (config.DRY_RUN && write) {
    log(`DRY_RUN — WOULD POST ${url} (GraphQL mutation)`);
    log('DRY_RUN — query:', query.replace(/\s+/g, ' ').trim());
    log('DRY_RUN — variables:', JSON.stringify(variables, null, 2));
    return { ok: true, dryRun: true, method: 'POST', url, query, variables, status: 0, json: { data: syn } };
  }

  if (!config.SHOPIFY_FULFILLMENT_TOKEN) {
    log(`NO TOKEN — WOULD POST ${url} (GraphQL ${write ? 'mutation' : 'query'}; SHOPIFY_FULFILLMENT_TOKEN unset)`);
    log('NO TOKEN — query:', query.replace(/\s+/g, ' ').trim());
    log('NO TOKEN — variables:', JSON.stringify(variables, null, 2));
    return { ok: true, dryRun: true, noToken: true, method: 'POST', url, query, variables, status: 0, json: { data: syn } };
  }

  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'X-Shopify-Access-Token': config.SHOPIFY_FULFILLMENT_TOKEN,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: JSON.stringify({ query, variables: variables || {} }),
  });
  let json = null;
  try { json = await res.json(); } catch { json = null; }
  return { ok: res.ok, status: res.status, method: 'POST', url, json };
}

module.exports = {
  request, graphql, createGiftCard, updateCustomer, getCustomer, getCustomerMetafield, findCustomerByEmail, addTags,
  setCustomerMetafield, createWebhook, base,
};