← 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
M 5x/REPORT.mdM lib/verify.jsM server.js
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 →