ガイド 第31

リード獲得パイプライン

@event/leads

@event/leads は公開フォームからの問い合わせ(= リード)を受けて 確実に保存し、通知は後追いする ためのパイプライン。feedback の submit.ts(honeypot / uaHash / Web 標準 Request→Response)と booking の reservation.ts(zod 入力スキーマ + 段構えの submit)を採取元に、依存は zod のみの server-only lib(UI 非同梱・Next 非依存)へ一般化した。永続(LeadStore)・通知(LeadSink)・レート制限(LeadRateLimiter)はすべて注入で、実体(Blob / DB / Resend / Slack)をパッケージに持ち込まない。

入力スキーマ — leadInputSchema

name のみ必須、email / company / message / source は任意(「まず名乗るだけ」のリードも受ける)。honeypot と ip はスキーマに含めない:honeypot は bot 判定用で保存レコードに持ち込まず、ip は自己申告を信用せず handler がヘッダから確定する。

export const leadInputSchema = z.object({
  name: z.string().trim().min(1).max(120),
  email: z.string().trim().email().max(200).optional(),
  company: z.string().trim().max(160).optional(),
  message: z.string().trim().max(4000).optional(),
  source: z.string().trim().max(80).optional(),
});

createLeadHandler — 処理順が設計の核

createLeadHandler({ store, honeypotField='website', rateLimit?, fanOut? })(req: Request) => Promise<Response> を返す。route handler の冒頭でそのまま呼べる(export function POST(req){ return leadHandler(req) })。処理順は固定で、各段の順番自体に理由がある:

  1. honeypot — 隠しフィールド(既定 website)に値があれば bot。成功に見せて静かに 200 を返す(保存も通知もしない)。bot に成否を悟らせないため、エラーではなく { ok:true }
  2. rate-limit — 送信元 IP(x-forwarded-for / x-real-ip から確定。self-report は使わない)で LeadRateLimiter.allow(key) を引く。超過は 429 { reason:"rate_limited" }検証より前に置くのは、無効な連投で zod / 永続を回さないため。
  3. 検証(zod)leadInputSchema.safeParse。失敗は 400 { reason:"invalid", errors }(フィールド別)。honeypot / ip はスキーマ外なので raw をそのまま渡してよい(未知キーは無視される)。
  4. persist-firststore.appendLead(lead)fan-out より先に 実行する。失敗したら 500 { reason:"persist_failed" } を返す。
  5. fan-outsinksPromise.allSettled で叩く。1 つ以上失敗しても 200(リードは既に保存済み)。

なぜ persist を先に置くのか

これがこの章の肝。もし「先に Slack 通知 → 後で保存」にすると、通知は届いたのに保存が失敗した場合、運営には見えているのに DB には無いリードが生まれ、追跡不能になる。逆に「保存 → 通知」なら、通知が落ちてもリードは残り、後から一覧で拾える。だから persist は必ず fan-out の前に置き、persist 失敗のみを 5xx にしてクライアントにリトライさせる。通知(fan-out)は best-effort で、その失敗はリード獲得の失敗にしない。

// 4) persist-first — 通知より前に確実に保存する
try {
  await options.store.appendLead(lead);
} catch (e) {
  console.error("[leads/handler] persist failed", e);
  return json({ ok: false, reason: "persist_failed" }, 500);
}

// 5) fan-out — best-effort。sink が失敗しても 200
if (sinks.length > 0) {
  const results = await Promise.allSettled(sinks.map((sink) => sink(lead)));
  for (const r of results) {
    if (r.status === "rejected") console.error("[leads/handler] sink failed", r.reason);
  }
}
return json({ ok: true, id: lead.id });

LeadStore — content-backed な既定実装

LeadStoreappendLead / listLeads だけの append-only インターフェース。createContentLeadStore({ contentStore, resource='leads' }) が既定実装で、feedback の fs 経路と同型に「現在の配列を読み → 先頭に足して書き戻す」。依存を zod だけに保つため @event/core を import せず、必要メソッドだけの構造的インターフェースContentStoreLike)を受ける — app が渡す createContentStore() の戻り値がこれを構造的に満たす(dev = content/leads.json、本番 = ContentStore の設定次第)。

リードは PII(氏名 / メール / 本文)を含む。git に置きたくない場合は、同じ LeadStore 型を満たす Blob 実装を app 側で差し替えるだけでよい(handler は LeadStore にしか依存しない)。

LeadSink — fan-out の差し込み

LeadSink = (lead: Lead) => Promise<void> が fan-out 先 1 つ。Slack / メールの実体は注入し、factory は「Lead → 通知の引数」への写像だけを担う(@event/mailer にも依存しない)。sink 内で try/catch はしない — best-effort の握りつぶしは handler の fan-out ループが行う。

// starter の配線: env が揃ったチャネルだけ sink を積む(未設定は no-op)
function buildSinks(): LeadSink[] {
  const sinks: LeadSink[] = [];
  if (process.env.SLACK_WEBHOOK_URL) {
    sinks.push(createSlackLeadSink({ send: (title, entries) => notifySlack(title, entries) }));
  }
  if (process.env.RESEND_API_KEY && LEAD_NOTIFY_TO) {
    sinks.push(createMailerLeadSink<NotifyData>({
      to: LEAD_NOTIFY_TO,
      toData: leadToNotifyData,
      send: (to, data) => sendMail("notify", to, data),
    }));
  }
  return sinks;
}

新しい通知先を足したいときは、LeadSink を 1 つ書いて fanOut 配列に push するだけ(CRM への webhook、Discord、Notion 追記…)。handler は fanOut の中身を知らないので、通知系の増減が保存ロジックに一切波及しない

レート制限 — 注入式

LeadRateLimiter.allow(key) を注入する(省略時は無制限)。最小実装 createMemoryRateLimiter({ windowSec, max }) はプロセスローカルのスライディングウィンドウ(依存なし)。分散環境で厳密な制限が要るなら KV / DB backed の LeadRateLimiter に差し替える。starter は同一 IP 60 秒 5 件。

starter の配線まとめ

  • lib/leads.tscreateContentLeadStore + createLeadHandler(rate-limit・fan-out を env から組む)。server 専用(client は import しない)
  • app/api/lead/route.tsPOST = (req) => leadHandler(req)
  • app/contact/page.tsx + ContactForm(client)— honeypot 隠しフィールド付き公開フォーム。送信は /api/lead 経由(@event/leads 非依存 = server-only barrel を client bundle に持ち込まない)
  • app/admin/leads/page.tsxadminAuth + features.isEnabled("leads") のダブルガードでリード一覧(PII は認証の内側でのみ描画)

feature flag は event.configleads: true を倒し、descriptor(nav /contact/admin/leads、env は RESEND / SLACK / LEAD_NOTIFY_TO いずれも optional)を 1 つ足すだけで nav・env チェックリストが追従する。