recipes / add-surveys

サーベイ & リーダーボードを追加する

既存イベント app に匿名アンケート + IMDb 加重リーダーボードを配線し、公開 submit API と admin 集計ページを動かす

前提: new-event前提: add-session-cards

このレシピは @event/feedback(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。

1. 依存を追加

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

"@event/feedback": "*"

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

2. 機能を有効化

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

surveys: true,

FEATURES に surveys descriptor が無ければ、cms を参考に nav /admin/surveys と env BLOB_READ_WRITE_TOKEN を持つ descriptor を追加する。starter には既に入っている。)

3. feedback の配線ファイルを作る

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

import { createFeedbackStore } from "@event/feedback";
import { store as contentStore } from "@/lib/cms"; // dev fs fallback に使う

/** dev は content/ に fs 保存、本番は BLOB_READ_WRITE_TOKEN で Blob。 */
export const feedbackStore = createFeedbackStore({
  contentStore,
  pathPrefix: "feedback/responses/",
});

4. 公開 submit ルート

apps/<app>/src/app/api/survey/[unitId]/submit/route.ts:

import { createSubmitHandler } from "@event/feedback";
import { createFoursConfig, listSessionsFromEvent } from "@event/fours-sdk";
import { feedbackStore } from "@/lib/feedback";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const handler = createSubmitHandler({
  store: feedbackStore,
  // unitId(=session id) を検証し、評価可能な subject(=speaker) を返す
  resolveUnit: async (unitId) => {
    const sessions = (await listSessionsFromEvent(createFoursConfig())) ?? [];
    const s = sessions.find((x) => x.id === unitId && x.visibility === "public");
    if (!s) return null; // 404
    return { ok: true, allowedSubjectIds: s.speakerIds };
  },
});

export async function POST(req: Request, ctx: { params: Promise<{ unitId: string }> }) {
  const { unitId } = await ctx.params;
  return handler(req, unitId);
}

5. admin 集計ページ

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

import { redirect, notFound } from "next/navigation";
import { cookies } from "next/headers";
import { createFoursConfig, listSessionsFromEvent, listSpeakersFromEvent } from "@event/fours-sdk";
import { rankSubjects } from "@event/feedback";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { feedbackStore } from "@/lib/feedback";

export const dynamic = "force-dynamic";

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

  const config = createFoursConfig();
  const [sessions, speakers] = await Promise.all([
    listSessionsFromEvent(config),
    listSpeakersFromEvent(config),
  ]);
  const publicSessions = (sessions ?? []).filter((s) => s.visibility === "public");
  const responses = await feedbackStore.listAllResponses(publicSessions.map((s) => s.id));
  const ranking = rankSubjects(responses);
  const nameById = new Map((speakers ?? []).map((s) => [s.id, s.name]));

  return (
    <main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
      <h1 style={{ fontSize: 24 }}>サーベイ結果</h1>
      <p style={{ fontSize: 13, opacity: 0.6 }}>
        回答 {ranking.totalResponses} 件 / 会場基準 {Math.round(ranking.baselineQuality)}
      </p>
      <ol style={{ paddingLeft: 20 }}>
        {ranking.subjects.slice(0, 30).map((s) => (
          <li key={s.subjectId} style={{ marginBottom: 6 }}>
            <b>{s.score}</b> — {nameById.get(s.subjectId) ?? s.subjectId}
            <span style={{ opacity: 0.5, fontSize: 12, marginLeft: 8 }}>{s.votes}票</span>
          </li>
        ))}
      </ol>

rankSubjects() の戻り値フィールドは subjectsSubjectRankingsubjectId / score / votes を持つ。) </main> ); }


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

monorepo root で:

yarn install yarn workspace <app> typecheck # 対象 app にスコープ(他パッケージの状態に依存しない)


## 7. 検証

yarn workspace <app> build # ビルドが通ること


dev で確認する場合(`BLOB_READ_WRITE_TOKEN` 未設定でも contentStore fs fallback で動く):

1. `yarn workspace <app> dev`
2. `POST /api/survey/<公開session id>/submit` に `{ "subjectRatings": { "<speakerId>": "great" } }` → `{ ok: true }`
3. `/admin/surveys` にスコアが出る(dev bypass でログイン不要)

完了条件: build が通り、submit が `ok:true` を返し、admin にランキングが描画される。