このレシピは @event/feedback(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。
1. 依存を追加
apps/<app>/package.json の dependencies に追加:
"@event/feedback": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@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() の戻り値フィールドは subjects。SubjectRanking は subjectId / 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 にランキングが描画される。