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