フィードバックボタンをリリースするのは簡単ですが、フィードバックシステムを構築するのは困難です。

違いは入力フォームではなく、誰かが送信ボタンを押した後の処理にあります:

  • APIが応答する前にレポートは保存されたか?
  • 調査に十分なコンテキストは含まれていたか?
  • 次のアクションの所有者は誰か?
  • チームは元のチャネルを通じて返信できるか?
  • 通知が失敗した場合はどうなるか?
  • 古いレポートが忘れ去られる前にどのように表示されるか?

このチュートリアルでは、TypeScript、Express、PostgreSQLを使用して、小規模ながら運用可能なフィードバックワークフローを構築します。同じ設計は、バグレポート、機能リクエスト、サポート質問、製品内会話に適用できます。

ウィジェットではなくワークフローから始める

有用なフィードバックのライフサイクルは次のようにモデル化できます:

captured -> new -> acknowledged -> planned/resolved/closed
                    |
                    +-> needs_more_information

Enter fullscreen mode Exit fullscreen mode

すべてのアクティブなアイテムには3つのプロパティが必要です:

  1. 所有者:次の決定に責任を持つ人
  2. 次のアクションのタイムスタンプ:非アクティブ状態をクエリ可能にする
  3. 返信経路:報告者が返信を期待する場合

これらのプロパティがなければ、統合受信箱は単に無視するための統合された場所を作るだけです。

これから構築するシステムには4つの部分があります:

Browser or app
      |
      v
Feedback API -----> PostgreSQL
                         |
                         v
                   Transactional outbox
                         |
                         v
                 Notification worker
                         |
                         v
               Chat, email, or issue tracker

Enter fullscreen mode Exit fullscreen mode

PostgreSQLが信頼できる情報源です。チャットツールやメールはデータベースではなく配信の手段です。

1. フィードバック、コンテキスト、配信状態を保存する

UUID生成を有効にし、3つのテーブルを作成します:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE feedback (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  idempotency_key uuid NOT NULL UNIQUE,
  source text NOT NULL CHECK (
    source IN ('web', 'mobile', 'chat', 'email', 'api')
  ),
  message text NOT NULL CHECK (char_length(message) BETWEEN 1 AND 5000),
  context jsonb NOT NULL DEFAULT '{}'::jsonb,

  reporter_id text,
  reply_channel text,
  reply_ref text,

  status text NOT NULL DEFAULT 'new' CHECK (
    status IN (
      'new',
      'acknowledged',
      'needs_more_information',
      'planned',
      'resolved',
      'closed'
    )
  ),
  owner_id text,
  next_action_at timestamptz,

  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE feedback_event (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  feedback_id uuid NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
  event_type text NOT NULL,
  actor_id text,
  data jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE feedback_outbox (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  feedback_id uuid NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
  kind text NOT NULL,
  payload jsonb NOT NULL,
  attempts integer NOT NULL DEFAULT 0,
  available_at timestamptz NOT NULL DEFAULT now(),
  claimed_at timestamptz,
  delivered_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (feedback_id, kind)
);

CREATE INDEX feedback_open_next_action_idx
  ON feedback (next_action_at)
  WHERE status NOT IN ('resolved', 'closed');

CREATE INDEX feedback_outbox_pending_idx
  ON feedback_outbox (available_at)
  WHERE delivered_at IS NULL;

Enter fullscreen mode Exit fullscreen mode

contextには、診断フィールドの小さな許可リストを含める必要があります:

  • アプリケーションリリース
  • クエリパラメータを除く現在のルート
  • 機能名
  • ブラウザファミリ
  • リクエストまたはトレースID
  • 本当に有用で許可されている場合のアカウントプランまたは役割

文脈に応じたフィードバックを不意の監視にしないでください。アクセストークン、フォーム内容、完全なURL、任意のローカルストレージ、または機密性の高いユーザー属性は避けてください。

reply_refはコピーされた会話ではなく、不透明なチャネル識別子である必要があります。アクセスを許可したり個人データを含む場合は暗号化してください。

2. 最も有用なコンテキストを収集する

技術的コンテキストを収集する最適なタイミングは、ユーザーがレポートを送信するときです。後でリリースや画面を尋ねるのは避けられる作業を生み出します。

以下は、リトライセーフなべき等性を持つ最小限のブラウザクライアントです:

const release = document
  .querySelector<HTMLMetaElement>('meta[name="app-release"]')
  ?.content ?? 'unknown';

export async function submitFeedback(message: string): Promise<string> {
  // Generated once and reused across retries of this submission.
  const idempotencyKey = crypto.randomUUID();

  const body = {
    idempotencyKey,
    source: 'web',
    message,
    context: {
      pagePath: location.pathname,
      release,
      feature: document.body.dataset.feature ?? 'unknown'
    }
  };

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch('/api/feedback', {
        method: 'POST,
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body)
      });

      if (response.ok) {
        const result = await response.json();
        return result.id;
      }

      if (response.status < 500) {
        throw new Error(`Feedback rejected with ${response.status}`);
      }
    } catch (error) {
      if (attempt === 2) throw error;
    }

    await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
  }

  throw new Error('Feedback could not be submitted');
}

Enter fullscreen mode Exit fullscreen mode

べき等性キーは、ネットワークタイムアウトが複数の同一レポートを作成するのを防ぎます。本当に新しい送信には新しいキーを生成してください。

3. レポートと通知をアトミックに永続化する

よくある失敗は次のようになります:

await database.insert(feedback);
await sendChatNotification(feedback); // Process crashes here.

Enter fullscreen mode Exit fullscreen mode

フィードバックは存在しますが、誰も通知されません。順序を逆にするのも安全ではなく、通知が成功してもデータベースの挿入が失敗する可能性があります。

代わりにトランザクションアウトボックスを使用してください。レポートと通知要求は一緒にコミットされます。

import express from 'express';
import { z } from 'zod';
import { pool } from './db.js';

const app = express();
app.use(express.json({ limit: '16kb' }));

const feedbackInput = z.object({
  idempotencyKey: z.string().uuid(),
  source: z.enum(['web', 'mobile', 'chat', 'email', 'api']),
  message: z.string().trim().min(1).max(5000),
  context: z.object({
    pagePath: z.string().max(500).optional(),
    release: z.string().max(100).optional(),
    feature: z.string().max(100).optional(),
    traceId: z.string().max(200).optional()
  }).strict().default({})
});

function removeQueryAndFragment(path = ''): string {
  return path.split(/[?#]/, 1)[0].slice(0, 500);
}

app.post('/api/feedback', async (req, res) => {
  const parsed = feedbackInput.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({ error: 'Invalid feedback payload' });
  }

  const input = parsed.data;
  const context = {
    ...input.context,
    pagePath: removeQueryAndFragment(input.context.pagePath)
  };

  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const inserted = await client.query<{ id: string }>(
      `INSERT INTO feedback (
         idempotency_key, source, message, context, reporter_id
       )
       VALUES ($1, $2, $3, $4::jsonb, $5)
       ON CONFLICT (idempotency_key) DO NOTHING
       RETURNING id`,
      [
        input.idempotencyKey,
        input.source,
        input.message,
        JSON.stringify(context),
        // Derive identity from trusted authentication middleware.
        req.user?.id ?? null
      ]
    );

    let feedbackId: string;

    if (inserted.rowCount === 1) {
      feedbackId = inserted.rows[0].id;

      await client.query(
        `INSERT INTO feedback_outbox (feedback_id, kind, payload)
         VALUES ($1, 'feedback.created', $2::jsonb)`,
        [feedbackId, JSON.stringify({ source: input.source })]
      );
    } else {
      const existing = await client.query<{ id: string }>(
        `SELECT id FROM feedback WHERE idempotency_key = $1`,
        [input.idempotencyKey]
      );
      feedbackId = existing.rows[0].id;
    }

    await client.query('COMMIT');
    return res.status(202).json({ id: feedbackId });
  } catch (error) {
    await client.query('ROLLBACK');
    console.error(error);
    return res.status(500).json({ error: 'Feedback could not be stored' });
  } finally {
    client.release();
  }
});

Enter fullscreen mode Exit fullscreen mode

本番環境では、適切な場所でレート制限、ボット保護、最大リクエストボディサイズを追加してください。クライアント提供のユーザーIDを信頼せず、認証されたセッションから導出してください。

4. 回収可能なワーカーで通知を配信する

複数のワーカーはFOR UPDATE SKIP LOCKEDで安全にジョブを要求できます:

async function claimJob() {
  const result = await pool.query(
    `WITH candidate AS (
       SELECT id
       FROM feedback_outbox
       WHERE delivered_at IS NULL
         AND available_at <= now()
         AND (
           claimed_at IS NULL OR
           claimed_at < now() - interval '5 minutes'
         )
       ORDER BY created_at
       FOR UPDATE SKIP LOCKED
       LIMIT 1
     )
     UPDATE feedback_outbox AS job
     SET claimed_at = now(), attempts = attempts + 1
     FROM candidate
     WHERE job.id = candidate.id
     RETURNING job.*`
  );

  return result.rows[0] ?? null;
}

async function runWorker() {
  while (true) {
    const job = await claimJob();

    if (!job) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      continue;
    }

    try {
      await notificationAdapter.send({
        eventId: job.id,
        type: job.kind,
        feedbackId: job.feedback_id,
        payload: job.payload
      });

      await pool.query(
        `UPDATE feedback_outbox
         SET delivered_at = now()
         WHERE id = $1`,
        [job.id]
      );
    } catch (error) {
      const delaySeconds = Math.min(300, 2 ** job.attempts);

      await pool.query(
        `UPDATE feedback_outbox
         SET claimed_at = NULL,
             available_at = now() + ($2 * interval '1 second')
         WHERE id = $1`,
        [job.id, delaySeconds]
      );
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

これは少なくとも1回の配信です。ワーカーが送信後にdelivered_atを更新する前にクラッシュした場合、通知が繰り返される可能性があります。送信リクエストにeventIdを含め、可能であれば宛先側のべき等性を使用してください。そうでない場合は、重複した通知が視覚的に無害であることを確認してください。

合理的な回数の失敗した試行の後、ジョブをデッドレター状態に移動し、アラートを発してください。無限のサイレントリトライは、別の種類の放置された受信箱です。

5. データモデルに所有権を組み込む

「新しいフィードバックが届いた」という通知は割り当てではありません。

状態変更とその履歴の両方を記録するトリアージ操作を作成します:

BEGIN;

UPDATE feedback
SET status = 'acknowledged',
    owner_id = $2,
    next_action_at = $3,
    updated_at = now()
WHERE id = $1
  AND status = 'new';

INSERT INTO feedback_event (
  feedback_id,
  event_type,
  actor_id,
  data
)
VALUES (
  $1,
  'feedback.assigned',
  $2,
  jsonb_build_object('nextActionAt', $3)
);

COMMIT;

Enter fullscreen mode Exit fullscreen mode

チームに適したポリシーを選択してください。例:

  • 日次トリアージ所有者をローテーションする。
  • 製品エリアごとに割り当てる。
  • セキュリティレポートを制限されたキューに送信する。
  • サポート質問と製品決定を分離する。
  • クローズされていないすべてのアイテムに次のアクションを要求する。

機能リクエストが必ずしもロードマップ作業になる必要はありません。「レビュー済みでクローズ」は、明示的であれば有効な結果です。

6. メモリに頼らず非アクティブ状態をクエリする

フィードバックチャネルは、無視が測定可能な場合に健全に保たれます。大規模な分析ダッシュボードではなく、いくつかの運用クエリから始めます。

-- New reports without an owner
SELECT id, source, message, created_at
FROM feedback
WHERE status = 'new'
  AND owner_id IS NULL
ORDER BY created_at;

-- Active reports whose next action is overdue
SELECT id, owner_id, status, next_action_at
FROM feedback
WHERE status NOT IN ('resolved', 'closed')
  AND next_action_at < now()
ORDER BY next_action_at;

-- Oldest pending notification
SELECT min(created_at) AS oldest_pending_delivery
FROM feedback_outbox
WHERE delivered_at IS NULL;

Enter fullscreen mode Exit fullscreen mode

有用な健全性シグナルには以下が含まれます:

  • 所有者がいない最も古いレポートの経過時間
  • 期限切れの次のアクションの数
  • 通知配信の失敗
  • キャプチャから確認までの時間
  • ユーザーからの情報を待っているレポート

実際に維持できるサービス約束からしきい値を設定してください。誰もがライブで監視することを期待されていない場合、チャネルを「ライブ」とラベル付けしないでください。

7. 返信経路を維持する

フィードバックはしばしば一方向のイベントではなく会話です。アウトバウンド返信をアダプターで表現します:

interface ReplyTarget {
  channel: 'web-chat' | 'email' | 'mobile' | 'none';
  reference?: string;
}

interface ReplyAdapter {
  send(target: ReplyTarget, message: string): Promise<void>;
}

Enter fullscreen mode Exit fullscreen mode

返信が成功した場合、feedback.reply_sentイベントを追加します。失敗した場合、元の通知と同じようにアウトボックスを通じてキューに入れます。

返信経路のない匿名フォームの場合、UIでそう明記してください。チャットや認証済みアプリケーションの場合、会話を継続するために必要なチャネル参照を保持してください。返信に実際に必要な場合を除き、メールアドレスの入力を求めないでください。

よくある失敗モード

チャットを永続ストレージとして扱う

チャット履歴は削除される可能性があり、アクセスは変更される可能性があり、メッセージはスクロールして見えなくなる可能性があります。チャットに通知する前に正規のレポートを保存してください。

制限なしにコンテキストを収集する

制限のないクライアントペイロードは、秘密を漏洩したり、大きすぎる行を作成したりする可能性があります。厳格なスキーマ、フィールド長制限、許可リストを使用してください。

すべてのリトライで新しいIDを生成する

これにより、一時的なネットワーク障害時に重複が発生します。同じ送信のすべての試行で1つのべき等性キーを再利用してください。

スタッフを配置せずにリアルタイムサポートを約束する

ライブウィジェットはユーザーの期待を変えます。期待される可用性を表示するか、即時応答が保証されない場合は非同期の表現を使用してください。

フィードバックを直接ロードマップに混在させる

レポートは証拠であり、自動的に要件ではありません。キャプチャ、トリアージ、製品優先順位付けを別々のステップとして保持してください。

応答せずにアイテムをクローズする

ユーザーが返信を期待する場合、内部ステータス更新では不十分です。アウトバウンド配信をワークフローの一部として追跡してください。

キューに所有者がいない

共有責任はしばしば責任の不在になります。名前付きの人物、ローテーション、または決定論的なルーティングルールを使用してください。

創業者主導のライブチャット向けのホスト型オプション

ワークフローが定義された後、ホスト型の連絡レイヤーが一部のカスタムキャプチャと返信チャネルコードを置き換えることができます。

Knocketは、メーカーや小規模チーム向けの実装例です。共有可能な連絡ページ、埋め込み可能なウェブライブチャットウィジェット、モバイルWebView SDK、統合受信箱を提供します。ウェブサイトのインストールはカスタムバックエンドを必要とせず、スクリプトタグを使用し、訪問者はアカウントなしでチャットを開始できます。

Knocketのセットアップでは、メッセージをTelegramにルーティングでき、引用されたTelegram返信をウェブサイト訪問者に配信できます。これにより創業者主導の返信が便利になりますが、所有権、保持決定、エスカレーションルール、または別個の製品バックログの必要性を排除するものではありません。

ホスト型オプションを評価する際は、以下を確認してください:

  • データ処理がプライバシーポリシーに合致しているか
  • 会話履歴が必要に応じてエクスポートまたは保持できるか
  • 障害と配信失敗がどのように表示されるか
  • 匿名訪問者に対して返信経路が機能するか
  • チャネルが実際に満たせる応答時間の期待を生み出していないか

運用ループを出荷する

フィードバックのエントリポイントを公開する前に、以下を確認してください:

  • 送信と通知の意図がアトミックにコミットされている
  • リトライが重複レポートを作成していない
  • コンテキストが最小限で、許可リストに登録され、プライバシーを考慮している
  • すべてのアクティブなレポートに所有者と次のアクションを設定できる
  • 失敗した通知が再試行され、観測可能である
  • チームが約束したときに元のチャネルを通じて返信できる
  • 期限切れのレポートがクエリ、ダイジェスト、またはアラートに表示される
  • フィードバックをクローズすることはロードマップに作業を追加することとは別である

目標はより多くのメッセージを収集することではありません。ユーザーコンテキストから所有された決定への短く信頼できる経路を作成し、適切な場合にはユーザーへのフィードバックを返すことです。

開示:私はKnocketで働いているため、これを中立的な推奨ではなく、実装例の1つとして扱ってください。