このレシピは @event/leads(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。 create-event-app で生成した app は starter の全ファイルを複製済みなので、実際には 2 の flag を 立てるだけで動く — 以下はファイルが無い app(手組み / 収斂中の app)への完全転写。
処理順序(このパイプラインの核)
createLeadHandler の処理順は固定: honeypot → rate-limit → 検証 → persist-first → fan-out
- honeypot — 隠しフィールド
websiteに値があれば bot。静かに 200 を返す
(保存も通知もしない。成功に見せて bot に判定条件を悟らせない)。
- rate-limit — 送信元 IP(
x-forwarded-for/x-real-ipヘッダ由来。body の自己申告 IP は
信用しない)で同一 IP 60 秒 5 件。超過は 429。
- 検証 — zod(
leadInputSchema)。name 必須・他は任意(メール未記入の「まず名乗るだけ」も受ける)。
失敗は 400 + フィールドエラー。
- persist-first —
store.appendLead()を通知より先に実行。失敗は 500(= クライアントが再送できる)。 - fan-out — Slack / メール通知を
Promise.allSettledで best-effort。sink が失敗しても 200。
順序の理由: 安価な判定(honeypot / rate-limit)を先頭に置いて無駄な保存・通知を止め、 保存を通知より先に確定する。これで「Slack webhook が死んでいた」「Resend キーが切れていた」 でもリードを失わない — リード獲得の成否は永続化だけで決まり、通知は後追いの副作用にすぎない。
1. 依存を追加
apps/<app>/package.json の dependencies に追加(fan-out のメール写像型に @event/mailer も使う。 既にあれば不要):
"@event/leads": "*",
"@event/mailer": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@event/leads"(未登録なら "@event/mailer" も)を足す。
2. 機能を有効化(event.config.ts)
apps/<app>/src/event.config.ts の features で:
leads: true, // ← @event/leads のリード獲得(公開 /contact・admin /admin/leads)。通知は RESEND/SLACK 未設定なら no-op
FEATURES 配列に leads descriptor が無ければ追加する(starter には既に入っている。以下は実物の転写):
{
key: "leads",
label: "リード獲得",
description:
"公開フォーム → honeypot・rate-limit・検証 → persist-first → fan-out(Slack/メール)(@event/leads)。dev は content/leads.json に保存、通知は RESEND/SLACK 未設定なら no-op",
nav: [
{ id: "lead-public", group: "リード", label: "お問い合わせ(公開)", href: "/contact", code: "CT", order: 1, external: true },
{ id: "lead-admin", group: "リード", label: "リード", href: "/admin/leads", code: "LEAD", order: 2 },
],
env: [
{ name: "SLACK_WEBHOOK_URL", required: false, description: "リード着信の Slack 通知(未設定なら sink を積まず no-op)" },
{ name: "RESEND_API_KEY", required: false, description: "リード着信のメール通知(未設定なら sink を積まず no-op)" },
{ name: "LEAD_NOTIFY_TO", required: false, description: "リード通知メールの宛先(運営受信箱)。RESEND と両方揃うとメール通知が有効" },
],
manualChapters: [31],
// リードは content/leads.json(dev)に保存。永続は ContentStore 経由なので requires なし
// (通知先の env が無くても保存は動く = persist-first)。
},
3. leads の配線ファイルを作る
apps/<app>/src/lib/leads.ts — store / rate-limit / sinks / handler を 1 箇所で組む。 fan-out は starter 標準の @/lib/mailer(notifySlack / sendMail)に依存する。無い app は先に recipe add-mailer を実行するか、fanOut: [] にして通知なしで始めてよい(保存は mailer 非依存)。
import {
createContentLeadStore,
createLeadHandler,
createMailerLeadSink,
createMemoryRateLimiter,
createSlackLeadSink,
type Lead,
type LeadHandler,
type LeadSink,
type LeadStore,
} from "@event/leads";
import type { NotifyData } from "@event/mailer";
import { store as contentStore } from "@/lib/cms"; // dev fs fallback(content/leads.json)に使う
import { notifySlack, sendMail } from "@/lib/mailer";
/**
* starter の leads 配線 — `@event/leads` を 1 箇所で組み、store + handler を app に供給する。
*
* このファイルは **server 専用**(@event/leads は node:crypto、@event/mailer は resend/process.env を
* 触る)。client の入力フォームは `/api/lead` を叩くだけで、この lib を import しない。
*
* fail-safe: fan-out(Slack / メール)は **キー未設定なら sink を積まない**(no-op)。
* SLACK_WEBHOOK_URL / RESEND_API_KEY が無い dev でも、リードは content/leads.json に保存され、
* 通知だけが黙ってスキップされる(永続 → 通知の順で、通知失敗はリードを失わせない)。
*/
/** 通知メールの宛先(運営の受信箱)。未設定なら メール sink を積まない。 */
const LEAD_NOTIFY_TO = process.env.LEAD_NOTIFY_TO;
/** リード → 運用通知メール(notify テンプレ)のデータへ写像する。 */
function leadToNotifyData(lead: Lead): NotifyData {
return {
eyebrow: "LEAD",
title: "新しいリード",
intro: `${lead.name} さんから問い合わせがありました。`,
sections: [
{
heading: "詳細",
items: [
`氏名: ${lead.name}`,
`メール: ${lead.email ?? "—"}`,
`会社: ${lead.company ?? "—"}`,
`流入元: ${lead.source ?? "—"}`,
`メッセージ: ${lead.message ?? "—"}`,
],
},
],
};
}
/** 有効な fan-out sink を env から組む(未設定のチャネルは積まない = no-op)。 */
function buildSinks(): LeadSink[] {
const sinks: LeadSink[] = [];
if (process.env.SLACK_WEBHOOK_URL) {
// notifySlack は keyValueBlocks + postToSlack。webhook 未設定なら元々 no-op だが、
// ここでも env ガードして「積まない」を明示する。
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;
}
/**
* リードの保存先。dev(GITHUB_* 未設定)は content/leads.json に fs 保存、
* 本番は ContentStore の設定に従う(PII を git に置きたくない場合は Blob 実装の
* LeadStore に差し替える — createContentLeadStore は既定の 1 実装)。
*/
export const leadStore: LeadStore = createContentLeadStore({
contentStore,
resource: "leads",
});
/**
* 公開フォーム送信の一気通貫ハンドラ。
* honeypot(website)→ rate-limit(同一 IP 60 秒 5 件)→ 検証(zod) → **persist-first** → fan-out。
* fan-out は best-effort(失敗しても 200)。永続を先に置くので通知失敗でリードを失わない。
*/
export const leadHandler: LeadHandler = createLeadHandler({
store: leadStore,
honeypotField: "website",
rateLimit: createMemoryRateLimiter({ windowSec: 60, max: 5 }),
fanOut: buildSinks(),
defaultSource: "contact-form",
});
4. 公開 submit ルート
apps/<app>/src/app/api/lead/route.ts:
import { leadHandler } from "@/lib/leads";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* 公開リード獲得 API。
* - `leadHandler(req)` に丸投げ(honeypot → rate-limit → 検証(zod) → persist-first → fan-out)。
* - honeypot(`website` 欄)に値があれば handler が静かに 200 を返す(bot に成否を悟らせない)。
* - fan-out(Slack / メール)は best-effort。失敗しても 200(リードは既に保存済み)。
* - IP は handler がヘッダ(x-forwarded-for / x-real-ip)から確定する(rate-limit / 監査用)。
*/
export function POST(req: Request): Promise<Response> {
return leadHandler(req);
}
5. 公開フォーム(来場者の入口)
apps/<app>/src/app/contact/page.tsx(server・認証不要・feature-gate):
import { notFound } from "next/navigation";
import { features } from "@/event.config";
import { ContactForm } from "./ContactForm";
export const dynamic = "force-dynamic";
/**
* 公開お問い合わせ(リード獲得)ページ。
* 入力は client の <ContactForm> が担い、送信は /api/lead 経由(@event/leads 非依存)。
* honeypot → rate-limit → 検証 → persist-first → fan-out は API 側の leadHandler が一気通貫で行う。
*/
export default function ContactPage() {
if (!features.isEnabled("leads")) notFound();
const label: React.CSSProperties = {
fontFamily: "var(--mono)",
fontSize: 11,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "var(--accent)",
margin: 0,
};
return (
<main style={{ maxWidth: 640, margin: "0 auto", padding: "56px 24px" }}>
<p style={label}>contact — お問い合わせ</p>
<h1 style={{ fontSize: 30, margin: "8px 0 4px" }}>お問い合わせ</h1>
<p style={{ fontSize: 13, color: "var(--ink-3)", margin: "0 0 28px" }}>
ご質問・出展相談などお気軽にお送りください。内容は運営に保存され、担当より折り返します。
</p>
<ContactForm />
</main>
);
}
apps/<app>/src/app/contact/ContactForm.tsx(client。/api/lead を叩くだけで @event/leads を import しない = node:crypto を含む server-only barrel を client bundle に 持ち込まない。honeypot website は画面外の隠しフィールドで送る):
"use client";
import { useState } from "react";
/**
* 公開リード獲得フォーム。/api/lead に投げる(@event/leads 非依存 = server-only barrel を
* client bundle に持ち込まない)。honeypot(website)は画面外の隠しフィールド。
*/
export function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [company, setCompany] = useState("");
const [message, setMessage] = useState("");
const [website, setWebsite] = useState(""); // honeypot(人間は触らない)
const [state, setState] = useState<"idle" | "sending" | "done">("idle");
const [msg, setMsg] = useState("");
const [ok, setOk] = useState(false);
async function submit() {
if (!name.trim()) {
setMsg("お名前を入力してください。");
return;
}
setState("sending");
setMsg("");
try {
const res = await fetch("/api/lead", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name, email, company, message, website, source: "contact-form" }),
});
const data = (await res.json()) as {
ok?: boolean;
reason?: string;
errors?: Record<string, string>;
};
if (data.ok) {
setOk(true);
setState("done");
setMsg("お問い合わせを受け付けました。担当より折り返しご連絡します。");
return;
}
setOk(false);
setState("idle");
setMsg(
data.reason === "rate_limited"
? "送信が集中しています。しばらくおいて再度お試しください。"
: data.errors
? Object.values(data.errors).join(" / ")
: "送信できませんでした。入力をご確認ください。",
);
} catch {
setState("idle");
setMsg("送信に失敗しました。もう一度お試しください。");
}
}
const field: React.CSSProperties = {
width: "100%",
padding: 10,
borderRadius: 8,
border: "1px solid var(--line)",
background: "var(--bg)",
fontSize: 14,
boxSizing: "border-box",
marginBottom: 10,
};
if (state === "done" && ok) {
return (
<div
style={{
border: "1px solid var(--line)",
background: "var(--surface)",
padding: "20px",
fontSize: 14,
}}
>
✓ {msg}
</div>
);
}
return (
<div
style={{
border: "1px solid var(--line)",
background: "var(--surface)",
padding: "20px",
}}
>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="お名前(必須)"
style={field}
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メール(折り返し用・任意)"
style={field}
/>
<input
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="会社名(任意)"
style={field}
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="お問い合わせ内容(任意)"
rows={5}
style={{ ...field, resize: "vertical" }}
/>
{/* honeypot — 画面外・スクリーンリーダー非表示。bot だけが埋める。 */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden
value={website}
onChange={(e) => setWebsite(e.target.value)}
style={{ position: "absolute", left: "-9999px", width: 1, height: 1 }}
/>
{msg && !ok && (
<p style={{ fontSize: 13, color: "var(--ink-2)", margin: "0 0 10px" }}>{msg}</p>
)}
<button
type="button"
onClick={submit}
disabled={state === "sending"}
style={{
width: "100%",
padding: 14,
borderRadius: 10,
border: "none",
background: state === "sending" ? "var(--ink-3)" : "var(--ink-1, #111)",
color: "#fff",
fontSize: 15,
fontWeight: 600,
cursor: state === "sending" ? "default" : "pointer",
}}
>
{state === "sending" ? "送信中…" : "送信する"}
</button>
</div>
);
}
6. admin リード一覧
apps/<app>/src/app/admin/leads/page.tsx(PII を含むので admin 認証の内側でのみ描画):
import { redirect, notFound } from "next/navigation";
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { leadStore } from "@/lib/leads";
export const dynamic = "force-dynamic";
const panel: React.CSSProperties = {
border: "1px solid var(--line)",
background: "var(--surface)",
padding: "16px 20px",
marginBottom: 20,
};
/**
* admin: リード一覧。
* - adminAuth(shared-password)+ features.isEnabled("leads") のダブルガード。
* - leadStore.listLeads() を新しい順で表示(dev は content/leads.json)。
* - PII を含むので admin 認証の内側でのみ描画する。
*/
export default async function AdminLeadsPage() {
if (!features.isEnabled("leads")) notFound();
const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");
const leads = await leadStore.listLeads().catch(() => []);
return (
<main style={{ maxWidth: 820, margin: "0 auto", padding: "48px 24px" }}>
<h1 style={{ fontSize: 24, margin: "0 0 4px" }}>リード</h1>
<p style={{ fontSize: 13, color: "var(--ink-3)", margin: "0 0 24px" }}>
<code>/contact</code> の公開フォームから届いた問い合わせです。保存(persist)を先に確定し、
Slack / メール通知(fan-out)は best-effort で後追いします(通知が落ちてもリードは残ります)。
</p>
<section style={panel}>
<h2 style={{ fontSize: 14, margin: "0 0 8px" }}>受信一覧({leads.length} 件)</h2>
{leads.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--ink-3)", margin: 0 }}>
まだリードはありません。<code>/contact</code> から送信するとここに出ます
(dev は <code>content/leads.json</code> に保存)。
</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--ink-3)" }}>
<th style={{ padding: "6px 4px" }}>受信</th>
<th style={{ padding: "6px 4px" }}>氏名</th>
<th style={{ padding: "6px 4px" }}>メール</th>
<th style={{ padding: "6px 4px" }}>会社</th>
<th style={{ padding: "6px 4px" }}>流入元</th>
<th style={{ padding: "6px 4px" }}>メッセージ</th>
</tr>
</thead>
<tbody>
{leads.map((l) => (
<tr key={l.id} style={{ borderTop: "1px solid var(--line)", verticalAlign: "top" }}>
<td style={{ padding: "8px 4px", whiteSpace: "nowrap", color: "var(--ink-3)" }}>
{new Date(l.createdAt).toLocaleString("ja-JP")}
</td>
<td style={{ padding: "8px 4px" }}>{l.name}</td>
<td style={{ padding: "8px 4px" }}>{l.email ?? "—"}</td>
<td style={{ padding: "8px 4px" }}>{l.company ?? "—"}</td>
<td style={{ padding: "8px 4px" }}>{l.source ?? "—"}</td>
<td style={{ padding: "8px 4px", maxWidth: 260 }}>{l.message ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}
7. 検証
monorepo root で:
yarn install
yarn workspace <app> typecheck # 対象 app にスコープ
yarn workspace <app> dev # 例: starter は http://localhost:3401
env は不要(SLACK_WEBHOOK_URL / RESEND_API_KEY / LEAD_NOTIFY_TO 未設定なら fan-out は sink を積まず no-op。保存だけは常に動く = persist-first の設計どおり)。
(a) 正常系 — 保存 → admin 表示
curl -s -X POST http://localhost:3401/api/lead \
-H "content-type: application/json" \
-d '{"name":"テスト太郎","email":"taro@example.com","message":"資料請求です"}'
# → {"ok":true,"id":"<uuid>"}
apps/<app>/content/leads.jsonに 1 件追記されている(先頭 = 最新)/admin/leadsに行が出る(dev はパスワード自動バイパス)- ブラウザで
/contactからも送信 → 完了メッセージが出て一覧に増える
(b) honeypot — 200 を返すが保存されない
curl -s -X POST http://localhost:3401/api/lead \
-H "content-type: application/json" \
-d '{"name":"bot","website":"http://spam.example"}'
# → {"ok":true} ※ id が無い偽装成功応答
content/leads.json の件数が増えていないこと、/admin/leads にも出ないことを確認する。 bot に「弾かれた」と悟らせないため、成功と同じ 200 を返すのが仕様。
(c) rate-limit(任意)
同一 IP から 60 秒以内に 6 回 POST → 6 回目が 429 {"ok":false,"reason":"rate_limited"}。
完了条件: typecheck が通り、(a) で保存 + admin 表示、(b) で 200 だが非保存、が確認できる。 通知を有効化する場合は .env.local に SLACK_WEBHOOK_URL(Slack)または RESEND_API_KEY + LEAD_NOTIFY_TO(メール)を設定して dev を再起動する — 通知が失敗してもリードは失われない(既に保存済みのため 200 のまま)。