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

マッチング & 名鑑ディレクトリを追加する

既存イベント app に @event/networking を配線し、公開名鑑ページ・admin 監査ページ・entity-linking デモを Neon 無し(content/directory.json)で動かす

前提: new-event解説 ch.32解説 ch.33解説 ch.34module: マッチング & ディレクトリ

このレシピは @event/networking(実装済み・純ライブラリ)をイベント app に配線する完全手順。 真実源は content/directory.json なので Neon 不要で dev が動く(本番で DB を使う場合は loadDirectorySubjects を registry テーブル取得に差し替えるだけ)。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。

1. 依存を追加

apps/<app>/package.json の dependencies に追加:

"@event/networking": "*"

apps/<app>/next.config.tstranspilePackages 配列に "@event/networking" を足す。

2. 機能を有効化

apps/<app>/src/event.config.ts の features で:

networking: true,

FEATURES に networking descriptor が無ければ足す(starter には既に入っている):

{
  key: "networking",
  label: "マッチング & ディレクトリ",
  description: "名鑑ディレクトリ + entity-linking + privacy 射影(@event/networking、dev は content/directory.json)",
  nav: [
    { id: "directory-admin", group: "ネットワーキング", label: "ディレクトリ", href: "/admin/directory", code: "DIR", order: 1 },
  ],
  env: [],
  manualChapters: [32, 33, 34],
},

DB を要求しない(dev は content/directory.json、requires 無し)。

3. サンプルデータ

apps/<app>/content/directory.json(privacy.ts の RegistrySubject 形 + 表示/マッチ用フィールド):

[
  {
    "slug": "yamada-taro",
    "name": "山田 太郎",
    "org": "株式会社ゼットフィルムズ",
    "cohort": 3,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "slug",
    "aliases": ["やまだ たろう", "Taro Yamada"],
    "contextTerms": ["映像", "東京"],
    "consents": [
      { "granted": true, "scope": "list_with_name" },
      { "granted": true, "scope": "contact" }
    ]
  },
  {
    "slug": "suzuki-hanako",
    "name": "鈴木 花子",
    "org": "フォーエス株式会社",
    "cohort": 3,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "manual",
    "aliases": ["すずき はなこ"],
    "contextTerms": ["イベント運営"],
    "consents": [{ "granted": true, "scope": "list_with_name" }]
  },
  {
    "slug": "sato-ken",
    "name": "佐藤 健",
    "org": "株式会社ゼットフィルムズ",
    "cohort": 3,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "slug",
    "aliases": [],
    "contextTerms": ["映像"],
    "consents": [{ "granted": true, "scope": "list_with_name" }]
  },
  {
    "slug": "takahashi-mei",
    "name": "高橋 芽衣",
    "org": "一般社団法人スパイクス",
    "cohort": 3,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "slug",
    "aliases": ["たかはし めい"],
    "contextTerms": ["学生団体", "関西"],
    "consents": [{ "granted": true, "scope": "list_with_name" }]
  },
  {
    "slug": "tanaka-riku",
    "name": "田中 陸",
    "org": "フォーエス株式会社",
    "cohort": 3,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "manual",
    "aliases": [],
    "contextTerms": ["イベント運営"],
    "consents": [{ "granted": true, "scope": "list_with_name" }]
  },
  {
    "slug": "ito-yui",
    "name": "伊藤 結衣",
    "org": "株式会社ゼットフィルムズ",
    "cohort": 4,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "slug",
    "aliases": ["いとう ゆい"],
    "contextTerms": ["映像", "東京"],
    "consents": [{ "granted": true, "scope": "list_with_name" }]
  },
  {
    "slug": "watanabe-sora",
    "name": "渡辺 空",
    "org": "一般社団法人スパイクス",
    "cohort": 4,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "slug",
    "aliases": [],
    "contextTerms": ["学生団体"],
    "consents": [{ "granted": true, "scope": "contact" }]
  },
  {
    "slug": "nakamura-jun",
    "name": "中村 純",
    "org": "フォーエス株式会社",
    "cohort": 5,
    "publishState": "public",
    "suppressed": false,
    "minor": false,
    "matchMethod": "name_norm",
    "aliases": [],
    "contextTerms": ["イベント運営"],
    "consents": [{ "granted": true, "scope": "list_with_name" }]
  }
]

このサンプルは 3 種の非掲載を仕込んである: watanabe-sora(consent scope が contact のみ = 氏名掲載同意なし → listable ×)、nakamura-junmatchMethod=name_norm = 氏名だけの自動マッチ → publishable ×)。残り 6 名が掲載され、cohort 3 は 5 名で k匿名を通過、cohort 4 は 1 名で内訳が秘匿される。

4. 配線ファイル(lib)

apps/<app>/src/lib/networking.ts:

import {
  applyKAnonymity,
  buildDictionary,
  canListWithName,
  evaluateListingGrants,
  isSubjectPublishable,
  linkFromText,
  matchName,
  safeScalar,
  sanitizeSubjectsForPublic,
  type ConsentRecord,
  type CountCell,
  type Dictionary,
  type ListingGrants,
  type MatchResult,
  type RegistrySubject,
} from "@event/networking";
import { store as contentStore } from "@/lib/cms"; // dev fs fallback(content/directory.json)

export interface DirectorySubject extends RegistrySubject {
  name: string;
  org: string;
  cohort: number;
  aliases?: string[];
  contextTerms?: string[];
  consents?: ConsentRecord[];
}

export interface PublicDirectoryCard {
  slug: string;
  name: string;
  org: string;
  cohort: number;
}

export interface CohortCount extends CountCell {
  cohort: number;
  count: number;
}

export interface SubjectAudit {
  subject: DirectorySubject;
  publishable: boolean;
  grants: ListingGrants;
}

const CONTENT_KEY = "directory";

export async function loadDirectorySubjects(): Promise<DirectorySubject[]> {
  return contentStore.readResourceFresh<DirectorySubject[]>(CONTENT_KEY, []);
}

// 二層防御: consent gating → 公開射影。
export function projectPublicDirectory(
  subjects: readonly DirectorySubject[],
): PublicDirectoryCard[] {
  const consented = subjects.filter((s) => canListWithName(s, s.consents ?? []));
  return sanitizeSubjectsForPublic<DirectorySubject, PublicDirectoryCard>(consented, (s) => ({
    slug: s.slug,
    name: s.name,
    org: s.org,
    cohort: s.cohort,
  }));
}

export async function getPublicDirectory(): Promise<PublicDirectoryCard[]> {
  return projectPublicDirectory(await loadDirectorySubjects());
}

export function cohortBreakdown(cards: readonly PublicDirectoryCard[]): CohortCount[] {
  const counts = new Map<number, number>();
  for (const c of cards) counts.set(c.cohort, (counts.get(c.cohort) ?? 0) + 1);
  const cells: CohortCount[] = [...counts.entries()]
    .map(([cohort, count]) => ({ cohort, count }))
    .sort((a, b) => a.cohort - b.cohort);
  return applyKAnonymity(cells);
}

export function publicTotal(cards: readonly PublicDirectoryCard[]): number | null {
  return safeScalar(cards.length);
}

export function buildDirectoryDictionary(subjects: readonly DirectorySubject[]): Dictionary {
  return buildDictionary(
    subjects.map((s) => ({
      id: s.slug ?? s.name,
      canonicalName: s.name,
      aliases: s.aliases,
      contextTerms: s.contextTerms ?? [s.org],
    })),
  );
}

export function matchDemo(query: string, subjects: readonly DirectorySubject[]): MatchResult {
  return matchName(query, buildDirectoryDictionary(subjects));
}

export function linkDemo(text: string, subjects: readonly DirectorySubject[]): MatchResult[] {
  return linkFromText(text, buildDirectoryDictionary(subjects));
}

export function auditSubjects(subjects: readonly DirectorySubject[]): SubjectAudit[] {
  return subjects.map((subject) => ({
    subject,
    publishable: isSubjectPublishable(subject),
    grants: evaluateListingGrants(subject, subject.consents ?? []),
  }));
}

5. 公開名鑑ページ(認証不要・feature-gate 不要)

apps/<app>/src/app/directory/page.tsx:

import { K_MIN } from "@event/networking";
import { cohortBreakdown, getPublicDirectory, publicTotal } from "@/lib/networking";

export const dynamic = "force-dynamic";

const panel: React.CSSProperties = {
  border: "1px solid var(--line)",
  background: "var(--surface)",
  padding: "16px 20px",
  marginBottom: 24,
};

export default async function DirectoryPage() {
  const cards = await getPublicDirectory();
  const cohorts = cohortBreakdown(cards);
  const total = publicTotal(cards);

  return (
    <main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
      <h1 style={{ fontSize: 26, margin: "0 0 4px" }}>参加者ディレクトリ</h1>
      <p style={{ fontSize: 13, opacity: 0.65, margin: "0 0 20px" }}>
        氏名つき掲載に同意した参加者のみ表示。集計は {K_MIN} 件未満のセルを秘匿する(k匿名)。
      </p>

      <section style={panel}>
        <h2 style={{ fontSize: 14, margin: "0 0 8px" }}>掲載 {total ?? `< ${K_MIN}`} 名</h2>
        <ul style={{ margin: 0, paddingLeft: 20, fontSize: 14 }}>
          {cards.map((c) => (
            <li key={c.slug} style={{ marginBottom: 6 }}>
              <b>{c.name}</b>
              <span style={{ color: "var(--ink-3)", fontSize: 12, marginLeft: 8 }}>
                {c.org} / {c.cohort}期
              </span>
            </li>
          ))}
        </ul>
      </section>

      <section style={panel}>
        <h2 style={{ fontSize: 14, margin: "0 0 8px" }}>期(cohort)別内訳 — k匿名</h2>
        {cohorts.length === 0 ? (
          <p style={{ fontSize: 13, color: "var(--ink-3)", margin: 0 }}>
            {K_MIN} 件以上のセルがありません。
          </p>
        ) : (
          <ul style={{ margin: 0, paddingLeft: 20, fontSize: 14 }}>
            {cohorts.map((cell) => (
              <li key={cell.cohort}>
                {cell.cohort}期 <b>{cell.count}</b> 名
              </li>
            ))}
          </ul>
        )}
      </section>
    </main>
  );
}

6. admin 監査ページ(adminAuth + feature-gate)

apps/<app>/src/app/admin/directory/page.tsx:

import { notFound, redirect } from "next/navigation";
import { cookies } from "next/headers";
import { AUTO_MIN, REVIEW_MIN } from "@event/networking";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { auditSubjects, linkDemo, loadDirectorySubjects, matchDemo } from "@/lib/networking";

export const dynamic = "force-dynamic";

export default async function AdminDirectoryPage() {
  if (!features.isEnabled("networking")) notFound();
  const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
  if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");

  const subjects = await loadDirectorySubjects();
  const audit = auditSubjects(subjects);
  const match = matchDemo("やまだ たろう", subjects);
  const links = linkDemo("本日の登壇者は山田太郎と鈴木花子です。", subjects);

  return (
    <main style={{ maxWidth: 820, margin: "0 auto", padding: "48px 24px" }}>
      <h1 style={{ fontSize: 26, margin: "0 0 16px" }}>ディレクトリ管理</h1>

      <table style={{ borderCollapse: "collapse", width: "100%", marginBottom: 24 }}>
        <thead>
          <tr>
            {["slug", "名称", "publishState", "matchMethod", "期", "publishable", "listable"].map((h) => (
              <th key={h} style={{ textAlign: "left", fontSize: 11, color: "var(--ink-3)", padding: "4px 10px" }}>
                {h}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {audit.map(({ subject, publishable, grants }) => (
            <tr key={subject.slug ?? subject.name}>
              <td style={{ fontSize: 12, padding: "4px 10px" }}>{subject.slug}</td>
              <td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.name}</td>
              <td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.publishState}</td>
              <td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.matchMethod}</td>
              <td style={{ fontSize: 13, padding: "4px 10px" }}>{subject.cohort}</td>
              <td style={{ fontSize: 13, padding: "4px 10px" }}>{publishable ? "◯" : "×"}</td>
              <td style={{ fontSize: 13, padding: "4px 10px" }}>{grants.listWithName ? "◯" : "×"}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <p style={{ fontSize: 13 }}>
        matchName(しきい値 auto ≥ {AUTO_MIN} / review {REVIEW_MIN}): 「やまだ たろう」→{" "}
        <b>{match.best?.canonicalName ?? "候補なし"}</b> / {match.decision} / score {match.best?.score ?? 0}
      </p>
      <p style={{ fontSize: 13 }}>
        linkFromText: {links.map((r) => r.best?.canonicalName).filter(Boolean).join(" · ") || "抽出なし"}
      </p>
    </main>
  );
}

7. マニュアル側の登録(任意)

apps/manual/src/content/index.ts に章 32 を import して CHAPTERS に足し、 apps/manual/src/lib/toc.tsc(32, ...)"published" にすると guide に「マッチング & ディレクトリ」章が現れる(33/34 は未執筆なら planned のまま)。

8. インストールと型チェック

monorepo root で:

yarn install
yarn workspace <app> typecheck   # 対象 app にスコープ

9. 検証(Neon 無しで動く)

yarn workspace <app> dev
  1. GET /directory(認証不要)→ 掲載 6 名。cohort 3 は「5 名」、cohort 4 は k匿名で内訳に出ない。

watanabe-sora(同意 scope 不足)と nakamura-jun(name_norm)は載らない。

  1. GET /admin/directory(dev bypass でログイン不要)→ 8 件の表で publishable/listable の ◯× が割れる。

matchName「やまだ たろう」→ 山田 太郎 / auto、linkFromText → 山田 太郎・鈴木 花子。

  1. content/directory.json を編集して掲載/非掲載が即反映されることを確認。

完了条件: typecheck が通り、/directory が二層防御で 6 名を出し、/admin/directory の監査表と entity-linking デモが描画される。