← back to Dw Signup Fulfillment

lib/retail-webhook.js

97 lines

'use strict';
// customers/create webhook handler — WIRED to the double-opt-in verify flow
// (Option C, DTD 2026-08-14). It no longer mints a gift card; it sends the customer a
// branded "confirm your email to unlock N free samples" letter (lib/verify.js). The
// reward (the VERIFIED_TAG that makes samples free at checkout) is applied only when
// the customer CLICKS the verify link — so this endpoint hands out no value, just an
// email, and the double opt-in also keeps junk/bot signups off the reward.
//
// Anti-forgery (unchanged intent): a forged/replayed POST to the public endpoint is
// authenticated by RE-FETCHING the customer from Shopify by id and using the REAL
// on-file email — a forged event can only ever mail a legitimate customer's own inbox.
//   1) AUTH      — unknown/forged id is not found in Shopify -> rejected.
//   2) FRESHNESS — refuse customers older than WEBHOOK_FRESHNESS_MIN (a real
//                  customers/create fires within seconds) so the endpoint can't be
//                  driven to spam the existing base.
//   3) IDEMPOTENCY — a custom.sample_verify_sent metafield sends the verify letter at
//                  most once per customer; replays/duplicates are no-ops.
const config = require('./config');
const shopify = require('./shopify');
const verify = require('./verify');

const SENT_FLAG = { namespace: 'custom', key: 'sample_verify_sent' };

// IN-PROCESS CONCURRENCY GUARD (closes the double-fire race).
// The durable idempotency (SENT_FLAG metafield, step 3/5) is a check-then-act with a
// long window: two near-simultaneous customers/create deliveries — Shopify delivers
// at-least-once and retries — both read the flag as false, both send, then both set it,
// so the customer gets TWO verify letters. The metafield alone can't stop that: there's
// no compare-and-swap on the REST read/write, and the send round-trip sits inside the gap.
// Because the app is a single pm2 fork (ecosystem.config.js: instances:1, exec_mode:'fork')
// every concurrent delivery shares one heap and one event loop, so an in-memory claim is
// sufficient AND atomic: there is NO `await` between the has()-check and the add(), so Node's
// single thread cannot interleave a second delivery into that window. Released in `finally`
// so a failed/throwing send never permanently poisons an id (a later retry/backfill re-sends).
// Division of labor: this Set owns the tight concurrent burst; the metafield still owns
// cross-restart / retry-after-minutes idempotency. Keyed on String(id) so a number-vs-string
// id shape can never defeat the guard.
const inFlight = new Set();

async function handleCustomerCreate(payload) {
  const id = payload && payload.id;
  if (!id) return { ok: false, reason: 'no_customer_id' };

  // 0) CONCURRENCY CLAIM — atomic in Node (no await between check and add). A second
  //    delivery for the same id while the first is still in flight is a no-op, guaranteeing
  //    exactly one verify letter per burst.
  const claimKey = String(id);
  if (inFlight.has(claimKey)) return { ok: true, skipped: 'in_flight', id };
  inFlight.add(claimKey);
  try {
    // 1) AUTH — re-fetch the customer from Shopify. Forged/unknown id ⇒ reject.
    const r = await shopify.getCustomer(id);
    const real = r && r.json && r.json.customer ? r.json.customer : null;
    if (!real || !real.id || !real.email) {
      return { ok: false, reason: 'customer_not_found_or_no_email', id };
    }

    // 2) FRESHNESS — only a freshly-created customer gets the letter. (Missing created_at
    //    ⇒ not real Shopify data ⇒ treated as stale/rejected.)
    const createdMs = real.created_at ? Date.parse(real.created_at) : NaN;
    const ageMin = Number.isNaN(createdMs) ? Infinity : (Date.now() - createdMs) / 60000;
    if (ageMin > config.WEBHOOK_FRESHNESS_MIN) {
      return { ok: false, reason: 'stale_customer', id, ageMin: Number.isFinite(ageMin) ? Math.round(ageMin) : null };
    }

    // 3) IDEMPOTENCY (durable) — send the verify letter at most once per customer, across
    //    restarts and retries-after-minutes.
    const flag = await shopify.getCustomerMetafield(id, SENT_FLAG.namespace, SENT_FLAG.key);
    if (flag && String(flag).toLowerCase() === 'true') {
      return { ok: true, skipped: 'already_sent', id };
    }

    // 4) SEND — the branded "confirm your email" letter to the REAL on-file email, with
    //    the real customer id baked into the token so the tag lands on the right account.
    const started = await verify.startVerification({ email: real.email, customerId: real.id, firstName: real.first_name });

    // 5) FLAG in Shopify so it never double-sends (survives restarts) — but ONLY when the
    //    letter actually went out. A failed send (missing VERIFY_SECRET, George down, etc.)
    //    must NOT poison the idempotency flag, or the customer is marked "handled" while
    //    having received nothing — permanently skipped, never gets their welcome email.
    if (started.ok) {
      await shopify.setCustomerMetafield(id, { ...SENT_FLAG, value: 'true', type: 'boolean' });
    } else {
      console.warn(`[retail-webhook] verify letter NOT sent for id=${id} (${started.reason || 'unknown'}) — flag left unset so a retry/backfill can re-send.`);
    }

    return { ok: started.ok, id, email: real.email, started };
  } finally {
    // Release the claim regardless of outcome — the durable metafield (set on success)
    // carries idempotency forward; a failure leaves both flag and claim clear so a retry
    // can re-send rather than being permanently skipped.
    inFlight.delete(claimKey);
  }
}

module.exports = { handleCustomerCreate, SENT_FLAG };