アンケートは「常時受付・常に匿名」。config を持たず、unit(=session) 受付判定 + subjectRatings + honeypot + uaHash dedup だけで成立する。実装は @event/feedback、公開フォームは第8章の来場者導線。 集計は第27章、ライブ投票は第28章。
送信ハンドラ — createSubmitHandler
Next 非依存の Web 標準 Request→Response を返す factory。route からは薄く呼ぶ:
import { createSubmitHandler } from "@event/feedback";
import { createFoursConfig, listSessionsFromEvent } from "@event/fours-sdk";
import { feedbackStore } from "@/lib/feedback";
const handler = createSubmitHandler({
store: feedbackStore,
resolveUnit: async (unitId) => {
const sessions = (await listSessionsFromEvent(createFoursConfig())) ?? [];
const s = sessions.find((x) => x.id === unitId && x.visibility === "public");
return s ? { ok: true, allowedSubjectIds: s.speakerIds } : null; // null = 404
},
});
export async function POST(req: Request, ctx: { params: Promise<{ unitId: string }> }) {
return handler(req, (await ctx.params).unitId);
}
検証の要点(すべて実装済み):
| 項目 | 挙動 |
|---|---|
| unit 受付 | resolveUnit が null / ok:false → 404 |
| subject 許可 | subjectRatings の key は allowedSubjectIds の subset のみ採用(他は黙って捨てる) |
| rating 値 | scale.keys(既定 great/good/ok)以外は無視。0 件なら no_ratings で 400 |
| honeypot | website 欄に値 → { ok:true, deduped:true } で静かに弾く |
| comment | maxCommentLength(既定 1000)で trim |
評価スケール — RatingScale / DEFAULT_FACE_SCALE
RatingScale は 2 系統の数値を持つ: values(0–100 格子。ランキング計算に使う)と scores(表示用。 既定 great=3/good=2/ok=1)。既定 DEFAULT_FACE_SCALE は 😍=great(値100/score3) / 😊=good(50/2) / 😐=ok(0/1)。イベント独自スケールは createSubmitHandler({ scale }) で注入する。
二段オートセーブ = uaHash upsert
「1 voter = 1 回答」は user-agent ハッシュで担保する。store.upsertResponse(rec) は同 uaHash の既存回答を 置換し { replaced } を返す:
const ua = req.headers.get("user-agent") ?? "unknown";
const uaHash = hashUserAgent(ua, unitId); // 個人特定はできない
これで「評価を先に保存 → あとからコメントを送って上書き付与」「やり直し」が全て 1 件に収束する (二重スキャン不要。bot の連投も uaHash 単位で 1 件)。応答は { ok, updated, avg, responseId }。
保存先 — createFeedbackStore
export const feedbackStore = createFeedbackStore({ contentStore, pathPrefix: "feedback/responses/" });
dev は content/ に fs 保存、本番は BLOB_READ_WRITE_TOKEN で Blob。PII(comment)を含むので git に置かない (fs fallback は dev 専用)。
公開フォーム(swipe/3顔 UX)
来場者は /survey/[unitId] の swipe フォームで送る。UI 本体は client 専用サブパス @event/feedback/ui の SwipeSurvey(1 subject = 1 カード。Pointer Events の左右ドラッグ ±80px で 右 = best / 左 = worst、scale.keys から生成される下ボタンで中間評価も可。全カード評価後に コメント + 送信、進捗ドット・reduced-motion 対応・依存ゼロ)。barrel は node:crypto を含むため client からは必ず /ui を import する。テーマは brandVars の --survey-* CSS 変数で上書きできる。 QR で /survey/<公開 session id> を配れば匿名評価が集まり、/admin/surveys(第27章のランキング)に反映される。
配線の完全手順はレシピ add-surveys。