レシピ · 7手 · 1プロンプト実装

リード獲得パイプラインを追加する

既存イベント app に公開お問い合わせフォーム → honeypot・rate-limit・検証 → persist-first → fan-out のリード獲得パイプラインを配線し、公開 /contact と admin /admin/leads を動かす

前提: new-event解説 ch.31module: リード獲得

このレシピは @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

  1. honeypot — 隠しフィールド website に値があれば bot。静かに 200 を返す

(保存も通知もしない。成功に見せて bot に判定条件を悟らせない)。

  1. rate-limit — 送信元 IP(x-forwarded-for / x-real-ip ヘッダ由来。body の自己申告 IP は

信用しない)で同一 IP 60 秒 5 件。超過は 429。

  1. 検証 — zod(leadInputSchema)。name 必須・他は任意(メール未記入の「まず名乗るだけ」も受ける)。

失敗は 400 + フィールドエラー。

  1. persist-firststore.appendLead() を通知より先に実行。失敗は 500(= クライアントが再送できる)。
  2. 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.tstranspilePackages 配列に "@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>"}
  1. apps/<app>/content/leads.json に 1 件追記されている(先頭 = 最新)
  2. /admin/leads に行が出る(dev はパスワード自動バイパス)
  3. ブラウザで /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.localSLACK_WEBHOOK_URL(Slack)または RESEND_API_KEY + LEAD_NOTIFY_TO(メール)を設定して dev を再起動する — 通知が失敗してもリードは失われない(既に保存済みのため 200 のまま)。