Rebounder Tech Blog

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

掲載枠のstart_monthが未設定だと、契約periodがNULLで満了処理が止まる

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

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

結論

掲載枠のstart_monthが未設定のまま申込が成立すると、生成される契約のperiodがNULLになり、期間を起点にする満了・自動更新・素材の期限切れがどれも無言で発火しなくなる。

結論

広告枠(placement_loops)は start_month(掲載開始月)が未設定のままでも published: true にできる。 この枠を公開一覧・申込フォームのどちらも特別扱いしていないと、そのまま申込が成立してしまい、生成される契約の period_start / period_endNULL になる。掲載期間はこの2列を起点にしているため、満了判定・自動更新・掲載素材の期限切れといった後続処理はどれも無言で発火しなくなる。

症状(見つかった経緯)

この状態は本番で実際に事故が起きて発覚したものではない。2026-09-15、本番反映前ゲートとして実施された独立した2人のレビュアーによるレビューで、2人とも同じ箇所を最重要指摘として挙げた。指摘は次の2点だった。

  • 公開一覧(getPublishedLoops)・申込用の単体取得(getLoopForBooking)のどちらも、start_month が未設定の枠を特別扱いせずそのまま返していた
  • 申込処理(bookLoopSlotCore)側も、掲載開始月から期間が計算できない(rangenull の)場合に申込を拒否せず、契約の period_start / period_endNULL のまま書き込んでいた

原因

公開一覧はこう書かれていた。

// src/lib/booking.ts:179-186(修正前)
export async function getPublishedLoops(): Promise<PublicLoop[]> {
  const admin = createSupabaseAdminClient();
  const { data, error } = await admin
    .from("placement_loops")
    .select(PUBLIC_LOOP_COLS)
    .eq("published", true)
    .is("bought_out_by_company_id", null)
    .order("created_at");

published: true と団体占有でないことだけを見ており、start_month の有無は条件に入っていない。URL 直叩き用の単体取得も同様だった。

// src/lib/booking.ts:219-227(修正前)
export async function getLoopForBooking(
  id: string,
  opts: { requirePublished: boolean; allowOccupied: boolean }
): Promise<PublicLoop | null> {
  const admin = createSupabaseAdminClient();
  let q = admin.from("placement_loops").select(PUBLIC_LOOP_COLS).eq("id", id);
  if (!opts.allowOccupied) q = q.is("bought_out_by_company_id", null);
  if (opts.requirePublished) q = q.eq("published", true);

つまり start_month が未設定でも published: true になっている行があれば、一覧にも詳細にもそのまま出る。そして実際に申込処理まで進んだときの扱いが、さらに問題だった。

// src/lib/booking.ts:1411-1415(修正前)
const startYm = staffStartMonth(input.startMonth, loop.start_month, jstMonth());
const range = periodRange(startYm, effectiveTerm);
const periodStr = publicPeriodLabel(startYm, effectiveTerm);

loop.start_month が無ければ rangenull になりうるが、この時点では申込を止める分岐が無い。処理はそのまま進み、契約の insert 部分でこう書かれていた。

// src/lib/booking.ts:1743(修正前)
period_start: range ? range.start : null,
period_end: range ? range.end : null,

rangenull なら、エラーにはせず period_start / period_end を素直に NULL として契約を作っていた。一覧が特別扱いしない・単体取得が特別扱いしない・申込処理も拒否しない、の3つが揃うと、start_month の抜けが黙って契約の欠損データにまで届く。

直し方

一覧と単体取得の両方に start_month が非 NULL である条件を追加した。

// src/lib/booking.ts:179-195(修正後、抜粋)
.eq("published", true)
.is("bought_out_by_company_id", null)
.not("start_month", "is", null)
.order("created_at");
// src/lib/booking.ts:228-238(修正後、抜粋)
if (opts.requirePublished)
  q = q.eq("published", true).not("start_month", "is", null);

そのうえで、申込処理そのものにも rangenull のときの明示的な拒否を足した。

// src/lib/booking.ts:1425-1438(修正後)
const range = periodRange(startYm, effectiveTerm);
if (!range)
  return {
    ok: false,
    error: opts.byStaff
      ? "この枠は掲載開始月が未設定のため、契約期間を確定できません。枠に開始月を設定するか、代理申込フォームの「掲載開始月」を指定してください。"
      : "申し訳ありません。この枠は現在お申し込みいただけません。お手数ですが運営までお問い合わせください。",
  };

一覧・単体取得・申込処理の3箇所を同じ条件(start_month が非 NULL)で揃えたことで、start_month が未設定の枠は棚にも出ず、URL を直接叩いても申込フォームに入れず、万一そこまで通っても最終処理で明示的に拒否されるようになった。

再発防止

修正のコメント自体に、直すときの規律がそのまま書かれている。

ここと bookLoopSlotCore必ず同じ条件にすること(片方だけ緩いと「棚に出ているのに最終送信で必ず拒否される行き止まり」が生まれる=0084 で実際に起きた事故と同型)。

つまり getPublishedLoops(一覧)・getLoopForBooking(単体取得)・bookLoopSlotCore(申込処理)の3箇所は、start_month の扱いについて常にセットで見る必要がある、という判断がコードのコメントとして残されている。3箇所のうちどれか1つだけを先に緩めたり厳しくしたりすると、今回のような欠損データではなく、今度は「表示されているのに申し込めない」という別の不具合を新たに作ってしまう。同じ条件を複数箇所に書かなければならないこと自体が、この種の食い違いの温床になっている。

よくある質問

Q1start_monthが未設定の枠を、なぜ保存時のガードだけでは防げないのですか?

保存時のガード(admin/loopsのvalidateLoopPublication)は、これから管理画面で保存する枠にしか効きません。すでに公開済みの行や、そのガードを通らない書き込み経路で作られた行には遡って効かないため、一覧・詳細取得・申込処理のそれぞれに同じ条件を置く必要があります。

Q2一覧の取得だけ直して、申込処理は直さなくてもいいのでは?

だめです。一覧(getPublishedLoops)だけstart_monthがNULLの枠を除外し、申込処理(bookLoopSlotCore)側の拒否を入れなければ、今度は『棚に出ている枠なのに申込の最終送信だけ必ず拒否される』という新しい行き止まりが生まれます。3経路を必ず同じ条件で揃える必要があります。

Q3periodがNULLの契約があると、具体的に何が止まるのですか?

契約の掲載期間(period_start/period_end)を起点にする満了判定・自動更新・掲載素材の期限切れといった処理が、期間の起点が無いために発火しません。契約自体は成立して見えるため、対応漏れに気づきにくくなります。

確認した環境

  • Next.js 16.2.7(App Router)/ kimiteras-portal
  • 2026-09-15、独立した2人のレビュアーによる本番反映前レビューで指摘、同日中に修正

この記事の根拠

  • TypeScriptファイル 179〜186行目コミット 8a654cb
  • TypeScriptファイル 219〜227行目コミット 8a654cb
  • TypeScriptファイル 1411〜1415行目コミット 8a654cb
  • TypeScriptファイル 1743〜1743行目コミット 8a654cb
  • TypeScriptファイル 179〜195行目コミット 4f71bfa
  • TypeScriptファイル 228〜238行目コミット 4f71bfa
  • TypeScriptファイル 1425〜1438行目コミット 4f71bfa

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