Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

まとめ申込みで対象枠が追加されなかったとき、bundleUnavailableを足すまで『割引なし』としか伝わらなかった

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

まとめ申込みの対象枠が追加されなかったとき、画面には「割引が適用されません」としか表示されず、対象枠自体が申し込まれなかったことは伝わっていなかった。

結論

複数の広告枠をまとめて申し込む機能で、対象枠が保有済み・満枠・DBエラーなどの理由で提示から消えたとき、完了メッセージは「割引が適用されません」としか伝えておらず、対象枠そのものが申し込みに追加されなかった事実を伝えていなかった。 チェックボックスを入れた人は「一緒に申し込めた」と思っている前提で、割引額の話だけでは足りない。

想定されていた事故

このフォームには、1枠を申し込むときに「対象のセット枠も一緒に」というチェックボックスがある。チェックすると、サーバー側でgetQualifierBundleOfferが対象枠を再計算し、保有済みでない枠をまとめて予約する。

const offer = await getQualifierBundleOffer(admin, loopId, result.companyId);
for (const m of offer?.members ?? []) {
  const r = await book(m.loopId);
  if (r.ok) bundleAdded.push(m.label);
  else bundleFailed.push(m.label);
}

offerがnullのとき、つまり対象枠がそもそも提示されなかったときは、このforは一度も回らない。bundleAddedもbundleFailedも空のままで、完了メッセージの組み立てはこの2つの配列と「割引が付いたか」しか見ていなかった。

const bundleNote = !bundleRequested
  ? ""
  : [
      bundleAdded.length > 0 ? `あわせて「${bundleAdded.join("」「")}」もお申し込みを受け付けました。` : "",
      bundleFailed.length > 0 ? `「${bundleFailed.join("」「")}」は満枠等のためお受けできませんでした。` : "",
      bundleDiscountApplied
        ? "セット割引は最後のお申し込み分の金額に反映されています。"
        : "セット割引の適用条件を満たさなかったため、今回は割引が適用されていません。金額について担当者よりご連絡します。",
    ].filter(Boolean).join("");

結果、offerがnullのときは4つの文言すべてが空になり、最後の「割引が適用されていません」だけが画面に残る。チェックした人からすると、「対象枠も申し込んだが割引だけ付かなかった」ように読めてしまうが、実際には対象枠は一枠も申し込まれていない。

原因

getQualifierBundleOfferのnullは、複数の異なる状態を一つにまとめた戻り値だった。対象枠が既に保有済み、満枠、セットが完成する見込みが無い、そして対象枠一覧の取得そのものがDBエラーで失敗した場合——これらすべてが同じnullとして返ってきていた。

const { data: loops } = await admin
  .from("placement_loops")
  .select(/* ... */)
  .in("id", toOffer);

ここではerrorを受け取らず、クエリが失敗してもloopsはundefinedのまま後続の処理に進み、結果的にnullを返す。呼び出し元のactions.tsは、このnullを「提案すべき対象枠が無かった」という一つの意味としてしか扱えず、「対象枠が無かったから何も起きていない」のか「エラーで提案できなかっただけで本来は対象枠があった」のかを区別する手段がなかった。

メッセージ生成側も、offerがnullだったという事実そのものを見ておらず、bundleAdded/bundleFailedが両方空だったという間接的な状態からしか判断できない構造だった。

直し方

修正は2箇所に分かれる。

1つ目はactions.ts側。offerの有無をbundleUnavailableという変数で直接記録し、提示が消えていた場合はメッセージの先頭にその旨を明示する。

const offer = await getQualifierBundleOffer(admin, loopId, result.companyId);
bundleUnavailable = !offer || offer.members.length === 0;
for (const m of offer?.members ?? []) {
  const r = await book(m.loopId);
  if (r.ok) bundleAdded.push(m.label);
  else bundleFailed.push(m.label);
}
const bundleNote = !bundleRequested
  ? ""
  : [
      bundleUnavailable
        ? "なお、まとめてお申し込みの対象枠は、既にご契約済みか現在お受けできない状態のため、今回は追加されていません。"
        : "",
      // ...bundleAdded / bundleFailed / bundleDiscountApplied は変更なし
    ].filter(Boolean).join("");

2つ目はpromo-bundle.ts側。対象枠取得のクエリでエラーを受け取り、黙って握りつぶさずログに残すようにした。

const { data: loops, error: loopsErr } = await admin
  .from("placement_loops")
  .select(/* ... */)
  .in("id", toOffer);
if (loopsErr) {
  console.error("[getQualifierBundleOffer] 対象枠の取得に失敗", loopsErr.message);
  return null;
}

戻り値としては引き続きnullのままだが、DBエラーが起きたこと自体はログから追えるようになった。あわせて、メンバーが2件未満なら割引が成立しないという判定(従来は関数の終盤、DBクエリの後にあった)をDBクエリより前に移し、不要な問い合わせをしないようにした。

再発防止

この状態は「レビュー指摘(N1)」で見つかり、同日中に修正されている。見つかった理由は、offerがnullになる複数の原因(保有済み・満枠・セット未完成・DBエラー)を洗い出し、「nullを受け取った呼び出し元は、その中のどれが起きたのかを一切区別できていない」という点に気づけたことだった。

複数の失敗理由を一つのnull(やfalse、空配列)にまとめる関数は、呼び出し元から見ると「何も起きなかった」のか「起きたが伝える手段が無かった」のかの区別が失われやすい。ユーザー向けのメッセージを組み立てる処理では、個々のフラグの有無ではなく、「サーバー側で実際に何が起きたか」を直接記録した値を見て文面を決める必要がある。

よくある質問

Q1なぜ対象枠が追加されなかったことが伝わらなかったのですか?

完了メッセージの文面は「割引が適用されたか」だけを見て組み立てられていました。対象枠が保有済み・満枠・非公開などの理由で提示自体が消えると、割引が付かないことは伝わっても、対象枠そのものが申し込みに追加されなかったことを示す文言がありませんでした。チェックした人は「一緒に申し込んだ」と思っているため、割引額の話だけでは不十分でした。

Q2この不具合は本番で実際に発生しましたか?

分かりません。このコミットはレビュー指摘(N1)に基づく同日中の修正で、レビューで気づいて直したものです。まとめ申込み自体は既に動いていた機能なので、修正前に実際のユーザーがこの状態に遭遇した可能性は否定できませんが、それを裏付ける記録は確認できていません。

Q3getQualifierBundleOfferがnullを返す原因は何ですか?

対象枠がもう保有済み・満枠・セット未完成・DB取得エラーのいずれかに当てはまる場合です。修正前はこれらを区別せず、呼び出し元は「null=提案することが無かった」としか扱えませんでした。

Q4DBエラーのときはどう動いていたのですか?

対象枠一覧の取得(placement_loopsへのクエリ)が失敗しても、エラーはどこにも記録されず、結果が0件だった場合と同じ扱いでnullを返していました。障害が起きても気づける経路がありませんでした。

Q5直し方はどう変えたのですか?

2点変えました。1つはactions.ts側で、提示が消えた場合に「対象枠は追加されていません」という一文を明示的にメッセージへ足したこと。もう1つはpromo-bundle.ts側で、対象枠取得のクエリが失敗したらconsole.errorでログに残してからnullを返すようにしたことです。あわせて、メンバー数が2件未満かの判定をDBクエリの前に移し、不要な問い合わせを避けるようにしました。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2 — kimiteras-portal
  • 2026-07-24 のレビュー指摘(N1)で発覚・同日中に修正

この記事の根拠

  • TypeScriptファイル 174〜192行目コミット 8d1878b
  • TypeScriptファイル 156〜199行目コミット 0cff778
  • TypeScriptファイル 227〜232行目コミット 8d1878b
  • TypeScriptファイル 229〜239行目コミット 0cff778

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。