ガイド 第26

サーベイ & フィードバック

匿名・3顔評価・二段オートセーブ

@event/feedback

アンケートは「常時受付・常に匿名」。config を持たず、unit(=session) 受付判定 + subjectRatings + honeypot + uaHash dedup だけで成立する。実装は @event/feedback、公開フォームは第8章の来場者導線。 集計は第27章、ライブ投票は第28章。

送信ハンドラ — createSubmitHandler

Next 非依存の Web 標準 RequestResponse を返す 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
honeypotwebsite 欄に値 → { ok:true, deduped:true } で静かに弾く
commentmaxCommentLength(既定 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/uiSwipeSurvey(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