[object Object]

← back to Dw Signup Fulfillment

contrarian gate: dedupe /verify confirmation email (fire once, not per re-click); fix runbook path in 5x report

33d6c4bd162d4501438b2b1111a9f84bc857aa0b · 2026-08-14 16:45:30 -0700 · steve

Files touched

Diff

commit 33d6c4bd162d4501438b2b1111a9f84bc857aa0b
Author: steve <steve@designerwallcoverings.com>
Date:   Fri Aug 14 16:45:30 2026 -0700

    contrarian gate: dedupe /verify confirmation email (fire once, not per re-click); fix runbook path in 5x report
---
 5x/REPORT.md  |  2 +-
 lib/verify.js | 13 ++++++++++++-
 server.js     | 17 +++++++++++------
 3 files changed, 24 insertions(+), 8 deletions(-)

diff --git a/5x/REPORT.md b/5x/REPORT.md
index ac6032d..5f0b77a 100644
--- a/5x/REPORT.md
+++ b/5x/REPORT.md
@@ -89,4 +89,4 @@ Open (go-live wiring, not defects): set `PUBLIC_URL` to the Kamatera host so mag
 1. `535e4f8` — `/claim` validates the email client-side (same regex as the server) so an empty/invalid address gives instant inline feedback and never fires a network 400.
 2. `e642d0d` — a tokenless/expired `/verify` link is a rendered human outcome, not a malformed API request, so it returns `200` (consistent with the existing completeVerification-failure page). `no_secret` still returns `503`. The page rendered correctly (M2 passed) — this reflects that, it does not weaken the assertion.
 
-**Open:** none on these two pages (stable across 2+ consecutive clean sweeps each). The full live claim→verify→tag→Regios money path stays gated behind go-live (DRY_RUN=1 here, no real send/tag/discount exercised) — covered by the go-live smoke test in `pending-approval/option-c-golive-runbook-20260814.md`.
+**Open:** none on these two pages (stable across 2+ consecutive clean sweeps each). The full live claim→verify→tag→Regios money path stays gated behind go-live (DRY_RUN=1 here, no real send/tag/discount exercised) — covered by the go-live smoke test in `~/.claude/yolo-queue/pending-approval/option-c-golive-runbook-20260814.md`.
diff --git a/lib/verify.js b/lib/verify.js
index 95ec96f..e824a25 100644
--- a/lib/verify.js
+++ b/lib/verify.js
@@ -86,10 +86,21 @@ async function completeVerification({ email: addr, customerId }) {
   let custId = customerId || null;
   if (!custId) custId = await shopify.findCustomerByEmail(addr);
   if (!custId) return { ok: false, reason: 'customer_not_found', email: addr };
+  // Detect first-time vs a re-click of the same verify link. The tag write is idempotent,
+  // but the "samples unlocked" confirmation email is NOT — without this, clicking the link
+  // three times sends three emails. Read current tags first so the caller can fire the
+  // confirmation exactly once. Failure to read defaults firstTime=true (fail toward the
+  // customer getting their email, not toward silence).
+  let alreadyTagged = false;
+  try {
+    const g = await shopify.getCustomer(custId);
+    const tags = (g && g.json && g.json.customer && g.json.customer.tags) || '';
+    alreadyTagged = tags.split(',').map((t) => t.trim().toLowerCase()).includes(String(config.VERIFIED_TAG).toLowerCase());
+  } catch (e) { /* non-fatal */ }
   const tagRes = await shopify.addTags(custId, [config.VERIFIED_TAG]);
   // Best-effort audit flag; never fatal (the tag is what actually gates the discount).
   try { await shopify.setCustomerMetafield(custId, { namespace: 'custom', key: 'sample_verified', value: 'true', type: 'boolean' }); } catch (e) { /* non-fatal */ }
-  return { ok: tagRes.ok !== false, customerId: custId, tag: config.VERIFIED_TAG, dryRun: tagRes.dryRun || false };
+  return { ok: tagRes.ok !== false, customerId: custId, tag: config.VERIFIED_TAG, dryRun: tagRes.dryRun || false, firstTime: !alreadyTagged };
 }
 
 module.exports = { mintToken, readToken, startVerification, completeVerification, baseUrl };
diff --git a/server.js b/server.js
index 00a5dfd..16cd7fb 100644
--- a/server.js
+++ b/server.js
@@ -172,12 +172,17 @@ app.get('/verify', async (req, res) => {
   if (!done.ok) {
     return res.status(200).type('html').send(verifyPage("We couldn't attach the samples to your account. Please make sure you're signed in with this email and try again — or reply to our email and we'll sort it out.", false));
   }
-  // Confirmation letter (fire-and-forget, DRY_RUN-safe).
-  (async () => {
-    const t = email.samplesUnlockedEmail({ firstName: parsed.email.split('@')[0], count: config.FREE_SAMPLE_COUNT });
-    const r = await email.sendEmail({ to: parsed.email, subject: t.subject, html: t.html, source: 'retail-verified' });
-    if (r && r.ok === false) console.error(`[verify] unlocked-email send FAILED for ${parsed.email}: ${r.error || r.status}`);
-  })().catch(e => console.error('[verify] unlocked-email error:', e.message));
+  // Confirmation letter — ONLY on the first verification, so re-clicking the link doesn't
+  // send duplicate "samples unlocked" emails (the tag write is idempotent; the email isn't).
+  if (done.firstTime) {
+    (async () => {
+      const t = email.samplesUnlockedEmail({ firstName: parsed.email.split('@')[0], count: config.FREE_SAMPLE_COUNT });
+      const r = await email.sendEmail({ to: parsed.email, subject: t.subject, html: t.html, source: 'retail-verified' });
+      if (r && r.ok === false) console.error(`[verify] unlocked-email send FAILED for ${parsed.email}: ${r.error || r.status}`);
+    })().catch(e => console.error('[verify] unlocked-email error:', e.message));
+  } else {
+    console.log(`[verify] repeat click for customer ${done.customerId} — tag re-applied, confirmation email skipped (already sent).`);
+  }
   console.log(`[verify] tagged customer ${done.customerId} '${done.tag}'${done.dryRun ? ' (DRY_RUN)' : ''}`);
   res.type('html').send(verifyPage(`Your ${config.FREE_SAMPLE_COUNT} free samples are unlocked. They'll show free at checkout — just add your swatches.`, true));
 });

← b32b988 5x RE-RUN 3: Option C /claim + /verify pages — 2 defects cau  ·  back to Dw Signup Fulfillment  ·  snapshot before restart: preserve in-flight work (auto-saved e398ba2 →