Newer
Older
client_dwatch / lib / errors / status-update.ts
/**
 * エラー一覧(`PATCH /errors/{error_id}/status`)の対応状況・備考・エラー内容を更新するときの規則(純粋関数のみ。指示書BD §3-3 / §5-4。D-184)。
 *
 * ★期待値表は `lib/errors/__tests__/status-update.test.ts`★。
 *
 * ★事前チェックの規則(`lib/prechecks/status-update.ts`)の上に `error_list_id` を足しただけ★ —
 *   状態・日時・備考は `parsePrecheckStatusUpdateBody()` / `planPrecheckStatusUpdate()` をそのまま使う(同じ列を2つの規則で書かない)。
 *   ★`lib/prechecks/status-update.ts` の挙動は変えない★(`/prechecks` は `error_list_id` を捨てるまま。指示書BD §2)。
 *
 * ★★パッチ(DB に書く列)を作るのは `planErrorStatusUpdate()` だけ★★ — リクエスト本文のキーが DB に届く経路を作らない。
 *
 * エラー内容(`error_list_id`)の規則(指示書BD §3-3。★この順に判定する★):
 *   0. 省略 → 変えない。★1以上の安全な整数でなければ(null・0・負数・小数・文字列)422★(解除は作らない)— `parseErrorStatusUpdateBody()`
 *   1. 現在値と同じ id → 検証せず、その列は書かない(無効化済みの id が入った行で状態だけ保存しても 422 にしない)
 *   2. 変わる場合: 行の `error_list_selectable` が false → 422(`details.error_list_id`)
 *   3. 変わる場合: id が有効な選択肢に無い(`error_lists` に無い・`inactive ≠ 0`・`deleted_at` が非 NULL)→ 422(`details.error_list_id`)
 *   4. ★パッチが `error_list_id` か `note` を含み★、行が選べる(`error_list_selectable`)とき:
 *      ★適用後★の `error_list_id` が `ERROR_LIST_NOTE_REQUIRED_IDS` に入り、★適用後★の備考が空(null か空白だけ)→ 422(`details.note`)
 *      — その他を選んで備考を書かない/既にその他の行で備考を消す、の両方。
 *      ★状態だけの要求では見ない★(その他・備考なしの行でも状態は変えられる。監督判断 2026-10-27)
 *
 * ★有効な選択肢かどうかは呼び出し側が引いて渡す★(`isActiveErrorListId`)— この関数は DB を読まない。
 *   ルートは `errorListIdToLookUp()` が null でないときだけ `db.errorLists()`(有効な行だけ)を引く。
 * ★`now` は引数で受け取る★(`planPrecheckStatusUpdate()` と同じ)。
 * ★エラーに入力値をエコーしない★(`lib/prechecks/status-update.ts` と同じ)。
 */
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { ERROR_LIST_NOTE_REQUIRED_IDS } from "@/lib/config";
import {
  parsePrecheckStatusUpdateBody,
  planPrecheckStatusUpdate,
  type PrecheckStatusCurrent,
} from "@/lib/prechecks/status-update";
import type { ErrorStatusPatch, ErrorStatusUpdateRequest } from "@/lib/types";

/** 更新前の行(API の値。`ErrorReportItem` から作る)。日時は真の瞬間の `Date` か null */
export interface ErrorStatusCurrent extends PrecheckStatusCurrent {
  /** `ErrorReportItem.error_list_id`(null = 未選択) */
  error_list_id: number | null;
  /** `ErrorReportItem.error_list_selectable`(★`error_type` はここでは見ない★。判定の正は `isErrorListSelectable()`) */
  error_list_selectable: boolean;
}

/**
 * `PATCH /errors/{error_id}/status` の本文を検証する(JSON として読めた後の値を受ける)。
 *
 * - `status` / `note` は `parsePrecheckStatusUpdateBody()` と同じ(同じ文言・同じ順で先に判定する)
 * - `error_list_id`: 無い(または undefined)→ 変えない。★1以上の安全な整数でなければ 422★
 *   (`note` の型違いと同じ書き方 — `details` は null。値をエコーしない)
 * - ★それ以外のキーは捨てる★(`updated_at` / `error_type` / `comment` など)
 */
export function parseErrorStatusUpdateBody(body: unknown): QueryParseResult<ErrorStatusUpdateRequest> {
  const base = parsePrecheckStatusUpdateBody(body);
  if (!base.ok) return base;
  // ここに来た時点で `body` はオブジェクト(配列・null でない)
  const record = body as Record<string, unknown>;
  if (!("error_list_id" in record) || record.error_list_id === undefined) {
    return base;
  }
  const value = record.error_list_id;
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
    return fail(422, "validation_failed", "error_list_idは1以上の整数で指定してください");
  }
  return { ok: true, query: { ...base.query, error_list_id: value } };
}

/**
 * 有効な選択肢かを引く必要がある id(手順3の対象)。★引かなくてよければ null★。
 * 省略・現在値と同じ(手順1)・選べない行(手順2で422になる)は null。
 */
export function errorListIdToLookUp(
  current: Pick<ErrorStatusCurrent, "error_list_id" | "error_list_selectable">,
  request: ErrorStatusUpdateRequest
): number | null {
  const requested = request.error_list_id;
  if (requested === undefined || requested === current.error_list_id) return null;
  if (!current.error_list_selectable) return null;
  return requested;
}

/**
 * 更新前の値と要求から「変える列だけ」のパッチを作る(キーが無い列は書かない)。★検証に落ちたら 422★。
 *
 * 状態・日時・備考は `planPrecheckStatusUpdate()` のまま。`error_list_id` は冒頭の手順1〜4。
 * `isActiveErrorListId` は手順3(変わる・選べる行)でだけ呼ぶ。
 */
export function planErrorStatusUpdate(
  current: ErrorStatusCurrent,
  request: ErrorStatusUpdateRequest,
  now: Date,
  isActiveErrorListId: (id: number) => boolean
): QueryParseResult<ErrorStatusPatch> {
  const { error_list_id: requested, ...precheckRequest } = request;
  const patch: ErrorStatusPatch = planPrecheckStatusUpdate(current, precheckRequest, now);

  // 手順1: 省略・現在値と同じ → 検証しない・書かない
  if (requested !== undefined && requested !== current.error_list_id) {
    // 手順2
    if (!current.error_list_selectable) {
      return fail(422, "validation_failed", "この行ではエラー内容を選べません", {
        error_list_id: ["この行ではエラー内容を選べません"],
      });
    }
    // 手順3
    if (!isActiveErrorListId(requested)) {
      return fail(422, "validation_failed", "error_list_idの値が不正です", {
        error_list_id: ["有効なエラー内容を指定してください"],
      });
    }
    patch.error_list_id = requested;
  }

  // 手順4(★適用後の値で判定★。同じ id のまま備考を消す要求も落とす)
  //   ★パッチがエラー内容か備考を変えるときだけ★(状態だけの要求は止めない — /prechecks の画面から同じ行の備考を
  //   消せるので、ここで状態の変更まで止めると対応状況を進められなくなる。監督判断 2026-10-27)。
  //   ★選べない行では見ない★。★備考は null・空白だけを空とみなす★(他所が書いた "" / 空白の comment もありうる)
  const touchesErrorContent = patch.error_list_id !== undefined || patch.note !== undefined;
  if (touchesErrorContent && current.error_list_selectable) {
    const nextErrorListId = patch.error_list_id ?? current.error_list_id;
    const nextNote = patch.note !== undefined ? patch.note : current.note;
    const noteIsEmpty = nextNote === null || nextNote.trim() === "";
    if (nextErrorListId !== null && ERROR_LIST_NOTE_REQUIRED_IDS.includes(nextErrorListId) && noteIsEmpty) {
      return fail(422, "validation_failed", "備考を入力してください", {
        note: ["このエラー内容を選んだときは備考が必須です"],
      });
    }
  }

  return { ok: true, query: patch };
}

/** パッチが空(何も書かない)か。★`error_list_id` も含めて見る★(変わらない id はパッチに入らない) */
export function isEmptyErrorPatch(patch: ErrorStatusPatch): boolean {
  return Object.keys(patch).length === 0;
}