Skip to content

feat(email): admin signup notifications — free + Pro funnel signal#47

Draft
SoapyRED wants to merge 3 commits into
mainfrom
feat/admin-signup-notifications
Draft

feat(email): admin signup notifications — free + Pro funnel signal#47
SoapyRED wants to merge 3 commits into
mainfrom
feat/admin-signup-notifications

Conversation

@SoapyRED
Copy link
Copy Markdown
Owner

Summary

Sprint admin-signup-notifications. Soap has been blind to the API funnel — Stripe-side notifications cover paid signups, but free-tier API key issuance (the actual top of the funnel) fired no internal alert. This PR adds a transactional admin email on every new free-tier signup AND on every Pro-tier checkout completion.

Three commits:

  • d4b1492 docs(audit) — Phase 1 diagnosis (lands first per hard rule). Traces three signup flows, identifies durable-creation moments, surveys existing Resend infrastructure, designs the helper/hook contract, and flags two out-of-scope findings (see below).
  • 191e022 feat(email)lib/email/admin-signup.ts helper + three call-site wirings (legacy /api/keys/register, magic-link /api/auth/verify, Stripe webhook /api/stripe/webhook).
  • 2bedd73 chore(release-hygiene) — Forced-error contract test, lint-chain wiring, CHANGELOG.md, lib/changelog-data.ts, STATE.md.

What ships

Helper (lib/email/admin-signup.ts) — mirrors lib/email/billing.ts:

  • notifyAdminFreeSignup({ email, source, useCase?, country?, referer?, signupTimeIso? })
  • notifyAdminProSignup({ email, country?, signupTimeIso? })

Recipient: contact@freightutils.com by default; overridable via ADMIN_NOTIFICATION_EMAIL (e.g. mcristoiu@gmail.com for direct-to-personal). Kill-switch: ADMIN_NOTIFICATIONS_ENABLED=false silences without a deploy. Rate-limit: 30/h via KV bucket admin-signup-notifs:${YYYY-MM-DDTHH}; beyond cap → console.warn and skip.

Subject:

  • [FreightUtils] New free signup: {email}
  • [FreightUtils] New Pro signup: {email}

Body (text only, mirrors billing.ts admin-email style): email, tier, UTC + UK timestamps (Intl.DateTimeFormat 'en-GB' Europe/London), country (IP geo for free flows, Stripe billing address for Pro), referer (free flows only — Stripe is server-to-server), source (legacy-form / magic-link-verify / stripe-checkout), optional use_case. No PII beyond email; no payment details, no Stripe IDs, no API key values.

Three hook points:

Flow File Trigger Notes
Free (legacy form) app/api/keys/register/route.ts After existing kv.set('key:...') + kv.set('email:...') Route short-circuits returning users earlier; reaching here is always first-time.
Free (magic-link) app/api/auth/verify/route.ts After createUser IF pre-getUser returned null Distinguishes new signup from returning sign-in (createUser is idempotent).
Pro app/api/stripe/webhook/route.ts checkout.session.completed after safeUpdateUserPlan + console.log('UPGRADE_TO_PRO:') No IP/referer (server-to-server).

Non-blocking guarantee: every call site uses void notify(...).catch(err => console.error(...)). The signup ack returns 200 even if Resend, KV, or the helper itself throws. Verified by scripts/admin-signup-notify-test.mjs, which:

  1. Asserts both helpers exported with the documented kill-switch + recipient + rate-limit constants.
  2. Tests the kill-switch boolean logic in isolation (unset → on; "true" → on; "false" → off; empty → on).
  3. Asserts every call site wraps with both void AND .catch(...) — either alone is insufficient.

Lint-chain wired: npm run lint:admin-signup-notify runs as part of npm run lint.

Out-of-scope flags (please surface to a separate PR)

🚨 Critical — safeUpdateUserPlan infinite recursion

app/api/stripe/webhook/route.ts:30-36:

async function safeUpdateUserPlan(email: string, plan: 'free' | 'pro', stripeCustomerId?: string): Promise<void> {
  try {
    await safeUpdateUserPlan(email, plan, stripeCustomerId);   // ← recurses into itself
  } catch (err) {
    console.warn('[webhook] KV plan update failed:', email, ...);
  }
}

Should call updateUserPlan (imported on line 3) — instead recurses into itself. The try/catch swallows the stack overflow, Stripe still acks with 200, but Pro KV plan upgrades have not been persisting since this code shipped. Customers paying Stripe are stuck at plan: 'free' in KV. The console.log('UPGRADE_TO_PRO:') line fires regardless, which is why the bug went unnoticed in Vercel logs.

This sprint's new notifyAdminProSignup fires correctly (no dependency on safeUpdateUserPlan success), so the admin email will land even with the bug present — but Soap should fix this + run a Stripe-cross-reference backfill sweep in a follow-up PR. One-line fix — rename the inner call.

Two parallel free-tier KV namespaces

app/api/keys/register/route.ts writes key:${apiKey} + email:${email} with the legacy { email, plan, created, use_case? } schema. lib/auth/kv.ts createUser writes user:${email} + key:${apiKey} with the User interface. Different apiKey formats (fu_${hex32} vs fu_live_${nanoid24}). A user can exist in one namespace and not the other. The Pro upgrade only updates the user:${email} namespace. Consolidation needs its own ADR — flagged in audit doc.

Self-test plan (post-deploy)

  • Confirm RESEND_API_KEY + (optionally) ADMIN_NOTIFICATION_EMAIL + ADMIN_NOTIFICATIONS_ENABLED are set on Vercel production.
  • Hit POST /api/keys/register with a throwaway email → confirm Soap inbox receives [FreightUtils] New free signup: {email} within seconds.
  • Hit POST /api/auth/login then click the magic link → confirm Soap inbox receives [FreightUtils] New free signup: {email} with Source: magic-link-verify.
  • Stripe test-mode checkout for Pro → confirm Soap inbox receives [FreightUtils] New Pro signup: {email} (this will hit the recursion bug for the KV update, but the email is independent).
  • Set ADMIN_NOTIFICATIONS_ENABLED=false in Vercel → re-test free signup → confirm no email arrives, signup ack still 200.
  • Sentry quiet via prod-curl 5xx proxy 10 min post-merge.

FAULT 5 checklist

Most items N/A — no new pages, no new API endpoints, no new MCP tools, no displayed-number changes. Items that apply:

  • CHANGELOG.md entry added
  • /changelog page (lib/changelog-data.ts) renders the new entry
  • STATE.md updated (Last updated: 20 May 2026 + new line in Observability)
  • Audit doc landed first (separate commit d4b1492)
  • Lint-chain test added (scripts/admin-signup-notify-test.mjs)
  • [N/A] siteStats.ts, app/sitemap.ts, public/openapi.json, /api-docs page, nav dropdown, homepage tool grid, MCP registration, footer links, freightutils-mcp README, npm bump, Postman, tool-page word count, withAuditRest (no new routes), generateMetadata (no new pages), indexnow-submit (no new URLs)

Sprint exit criteria

  1. docs/audit/admin-signup-notifications-2026-05-20.md committed first (commit d4b1492).
  2. ✅ Both free-tier and Pro-tier signup flows trigger admin email.
  3. ✅ Feature flag ADMIN_NOTIFICATIONS_ENABLED honoured.
  4. ✅ Non-blocking on Resend failure (verified by scripts/admin-signup-notify-test.mjs).
  5. ☐ Self-test by triggering a test signup against prod/preview (post-merge).
  6. ✅ FAULT 5 checklist applied.
  7. ✅ STATE.md updated under Observability.

Audit doc: docs/audit/admin-signup-notifications-2026-05-20.md


Generated by Claude Code

claude added 3 commits May 20, 2026 19:53
Diagnosis-only commit. Lands BEFORE any code is written per sprint hard
rule.

Three signup flows traced:

1. Legacy free-tier (POST /api/keys/register) — durable-create at
   route.ts:73-79 (kv.set 'key:${apiKey}' + 'email:${email}'). The
   route short-circuits returning users earlier at lines 60-71, so
   reaching the durable-create branch always means first-time signup.
   Data available: email, optional use_case, IP, headers (referer,
   x-vercel-ip-country, user-agent), timestamp.

2. Magic-link free-tier (GET /api/auth/verify) — durable-create at
   verify/route.ts:31 via createUser() from lib/auth/kv.ts:32-49.
   createUser is idempotent (returns existing user if found), so the
   notification must fire only when existing was null. Cleanest hook:
   double-read getUser() in verify/route.ts before createUser() so the
   route can branch on `existed`.

3. Pro-tier (POST /api/stripe/webhook, checkout.session.completed) —
   hook after webhook/route.ts:62. Data: session.metadata.email or
   session.customer_email, customer ID, session.customer_details.
   address.country (when present). No IP/referer (server-to-server).

CRITICAL out-of-scope bug flagged: safeUpdateUserPlan at
app/api/stripe/webhook/route.ts:30-36 recurses into itself instead of
calling updateUserPlan from the import on line 3. Pro KV plan upgrades
have not been persisting since this code shipped. Stripe still acks
with 200 because the try/catch swallows the stack overflow. One-line
fix (rename inner call). Separate PR + backfill sweep needed. NOT in
this PR's scope.

Also flagged out-of-scope: two parallel free-tier KV namespaces never
consolidated (legacy 'key:${apiKey}' + 'email:${email}' vs magic-link
'user:${email}' + 'key:${apiKey}', different apiKey formats, separate
ADR needed).

Existing Resend infrastructure surveyed: lib/email/billing.ts is the
pattern reference (lazy getResend, (resend, args) helper signature,
FROM 'FreightUtils Billing <noreply@freightutils.com>', ADMIN_TO
'contact@freightutils.com'). Non-blocking via .catch + void.

Phase 2 design decisions:
- New file lib/email/admin-signup.ts mirroring billing.ts pattern.
- Helpers: sendAdminFreeSignupEmail, sendAdminProSignupEmail.
- Subjects: "[FreightUtils] New free signup: {email}" /
  "[FreightUtils] New Pro signup: {email}".
- Body (text only): email, tier, signup time UTC + UK, country (IP
  geo or Stripe address), referer (free flows only), source (legacy
  form / magic-link verify / stripe checkout), optional use_case.
- ADMIN_NOTIFICATIONS_ENABLED env: default ON, disabled only when ===
  'false'. Killable via Vercel dashboard without rebuild.
- ADMIN_NOTIFICATION_EMAIL env: optional override of ADMIN_TO.
- Rate-limit: KV per-hour bucket admin-signup-notifs:${YYYY-MM-DDTHH},
  cap 30/h, console.warn fallback above cap. No full digest mode.
- Non-blocking: .catch on every call site, void to discard the
  promise; signup ack independent of admin-email success.
Phase 2 (helper + hook-point wirings) + Phase 3 (kill-switch + rate
limit + non-blocking) from the sprint. Phase 1 diagnosis is the prior
commit at docs/audit/admin-signup-notifications-2026-05-20.md.

lib/email/admin-signup.ts (new):

  Mirrors lib/email/billing.ts pattern — lazy Resend init via reused
  getResend(), (resend, args) helpers, no module-level side effects.
  Two exports:

    - notifyAdminFreeSignup({ email, source, useCase?, country?,
      referer?, signupTimeIso? })
    - notifyAdminProSignup({ email, country?, signupTimeIso? })

  Both honour the kill-switch internally (ADMIN_NOTIFICATIONS_ENABLED
  === 'false' disables; any other value including unset keeps it on).
  Both rate-limit via KV bucket `admin-signup-notifs:${YYYY-MM-DDTHH}`
  capped at 30/hour — beyond cap, console.warn and skip; missing the
  cap is preferable to losing the notification, so KV-read failure
  fails open. Recipient defaults to contact@freightutils.com,
  overridable via ADMIN_NOTIFICATION_EMAIL (e.g. mcristoiu@gmail.com
  for direct-to-personal).

  Subject lines match sprint spec: "[FreightUtils] New free signup:
  {email}" / "[FreightUtils] New Pro signup: {email}". Text body
  carries: email, tier, UTC + UK timestamps (Intl.DateTimeFormat
  en-GB Europe/London), country, referer (free only), source, optional
  use_case. No PII beyond email — no payment details, no Stripe IDs,
  no API key value.

Three call sites wired:

  app/api/keys/register/route.ts — after the existing kv.set pair on
    lines 78-79 ('key:${apiKey}' + 'email:${email}'). The route
    already short-circuits returning users earlier (lines 60-71), so
    reaching here is always first-time signup. country from
    x-vercel-ip-country header, referer from referer header. Uses the
    record.created ISO already generated for KV.

  app/api/auth/verify/route.ts — pre-read getUser before createUser so
    the route can distinguish first-time signup from returning sign-in.
    Notification fires only when existed === false. createUser is
    itself idempotent so the second-read is cheap. country/referer from
    request headers.

  app/api/stripe/webhook/route.ts — on checkout.session.completed, after
    the existing safeUpdateUserPlan call. country from
    session.customer_details.address.country when Stripe collected it
    during checkout. No IP/referer (server-to-server).

Non-blocking guarantee at every site: `void notify(...).catch(err =>
console.error(...))`. The signup ack returns 200 even if Resend, KV,
or the helper itself throws. Verified by static contract test in
Phase 4.

OUT-OF-SCOPE NOTE (untouched here, flagged in audit doc):
app/api/stripe/webhook/route.ts:30-36 — safeUpdateUserPlan recurses
into itself instead of calling updateUserPlan from the import on
line 3. Pro KV plan upgrades have silently failed since this shipped.
Separate PR + backfill sweep needed. This sprint adds the notification
ONLY; the new notifyAdminProSignup call runs after the bogus
safeUpdateUserPlan call so the email still fires regardless of the
KV-persistence bug.
… + STATE (Phase 4)

Phase 4 from the sprint — verification + paperwork.

scripts/admin-signup-notify-test.mjs (new):

  Static contract test enforcing the non-blocking guarantee + helper
  shape + kill-switch logic. Pure Node, no deps (matches lint-*.mjs
  pattern in repo).

  Asserts:
    1. lib/email/admin-signup.ts exports notifyAdminFreeSignup +
       notifyAdminProSignup; honours ADMIN_NOTIFICATIONS_ENABLED ===
       'false' kill-switch; honours ADMIN_NOTIFICATION_EMAIL override;
       defaults to contact@freightutils.com; declares HOURLY_CAP;
       uses the documented `admin-signup-notifs:${YYYY-MM-DDTHH}` KV
       bucket key shape.
    2. Kill-switch boolean logic in isolation: unset → on; "true" →
       on; "false" → off; empty → on.
    3. Every call site in app/api/keys/register/route.ts,
       app/api/auth/verify/route.ts, app/api/stripe/webhook/route.ts
       wraps the notify call in BOTH `void` AND `.catch(...)`. Either
       guard alone is insufficient. The combined wrapping is what
       guarantees signup ack independent of Resend/KV failure.

  Pass/fail exit code → wired into `npm run lint`. Runs in <50ms.

package.json:
  New lint:admin-signup-notify script; added to the `lint` chain so
  CI catches a future regression of the call-site contract.

CHANGELOG.md:
  New 2026-05-20 entry tagged Internal — admin signup notifications
  live, kill-switch, rate-limit, audit cross-reference, out-of-scope
  flag for the Stripe webhook recursion bug.

lib/changelog-data.ts:
  Parallel /changelog page entry, tag 'Bug Fix' (the user-visible
  framing — Soap was previously blind to the API funnel; this PR
  removes that blind spot). Both data sources updated per FAULT 5
  hard rule (until they collapse to one source).

STATE.md:
  Last-updated 16 May → 20 May. New line in the Observability section
  documenting: notification recipients, kill-switch env, rate-limit,
  helper location, audit cross-reference, out-of-scope Stripe
  webhook recursion bug.

FAULT 5 checklist applied (most items N/A — no new pages, no new API
endpoints, no new MCP tools, no displayed-number changes):

  ✓ CHANGELOG.md entry added
  ✓ /changelog page (lib/changelog-data.ts) renders new entry
  ✓ STATE.md updated
  ✓ Audit doc landed first (separate commit)
  ✗ N/A: siteStats.ts, app/sitemap.ts, public/openapi.json,
    /api-docs page, nav dropdown, homepage tool grid, MCP
    registration, footer links, freightutils-mcp README, npm bump,
    Postman, tool-page word count, withAuditRest (no new routes),
    generateMetadata (no new pages), indexnow-submit (no new URLs)
@vercel
Copy link
Copy Markdown

vercel Bot commented May 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
freighttools Ready Ready Preview, Comment May 20, 2026 8:02pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants