/**
* エラー一覧(`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;
}