@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) })。処理順は固定で、各段の順番自体に理由がある:
- honeypot — 隠しフィールド(既定
website)に値があれば bot。成功に見せて静かに 200 を返す(保存も通知もしない)。bot に成否を悟らせないため、エラーではなく{ ok:true }。 - rate-limit — 送信元 IP(
x-forwarded-for/x-real-ipから確定。self-report は使わない)でLeadRateLimiter.allow(key)を引く。超過は429 { reason:"rate_limited" }。検証より前に置くのは、無効な連投で zod / 永続を回さないため。 - 検証(zod) —
leadInputSchema.safeParse。失敗は400 { reason:"invalid", errors }(フィールド別)。honeypot / ip はスキーマ外なので raw をそのまま渡してよい(未知キーは無視される)。 - persist-first —
store.appendLead(lead)を fan-out より先に 実行する。失敗したら500 { reason:"persist_failed" }を返す。 - fan-out —
sinksをPromise.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 な既定実装
LeadStore は appendLead / 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.ts—createContentLeadStore+createLeadHandler(rate-limit・fan-out を env から組む)。server 専用(client は import しない)app/api/lead/route.ts—POST = (req) => leadHandler(req)app/contact/page.tsx+ContactForm(client)— honeypot 隠しフィールド付き公開フォーム。送信は/api/lead経由(@event/leads非依存 = server-only barrel を client bundle に持ち込まない)app/admin/leads/page.tsx—adminAuth+features.isEnabled("leads")のダブルガードでリード一覧(PII は認証の内側でのみ描画)
feature flag は event.config に leads: true を倒し、descriptor(nav /contact・/admin/leads、env は RESEND / SLACK / LEAD_NOTIFY_TO いずれも optional)を 1 つ足すだけで nav・env チェックリストが追従する。