/**
* PATCH /prechecks/{id}/status — アラート(事前チェック)の対応状況・備考を更新する(指示書AT 段階5)
*
* 仕様の正は `d-watch-api.yaml` の /prechecks/{id}/status。
*
* ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の4列
* (`status` / `comment` / `responded_at` / `resolved_at`)だけ(D-042 / `CLAUDE.md` §7)。
* ★書き込みの中身は `lib/db/robot-error-write.ts`★、★パッチを作るのは `planPrecheckStatusUpdate()` だけ★
* (リクエスト本文のキーが DB に届く経路を作らない)。
*
* ★順序は 形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し★
* (`app/api/ds-devices/[serial_number]/note/route.ts` と同じ考え方)。
* ★書けるのは `/alerts` に事前チェック異常として出る行だけ。出発失敗の id は 404★(§3-3)。
*
* ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。
*/
import { NextResponse } from "next/server";
import { parsePathErrorId } from "@/lib/api/path-params";
import { db } from "@/lib/db";
import { columnsForPatch } from "@/lib/db/robot-error-write";
import { toJstIso } from "@/lib/format/date";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { dbErrorCode, logDbWrite } from "@/lib/logger/db-write";
import type { DbWriteLogEvent } from "@/lib/logger/events";
import {
isEmptyPatch,
parsePrecheckStatusUpdateBody,
planPrecheckStatusUpdate,
} from "@/lib/prechecks/status-update";
import type { ApiError, ErrorCode, PrecheckItem, PrecheckStatusPatch } from "@/lib/types";
const ROUTE = "/api/prechecks/[id]/status";
const METHOD = "PATCH";
const NOT_FOUND_MESSAGE =
"対応状況を記録できるアラートが見つかりません(出発失敗には記録先がありません)";
function errorBody(
code: ErrorCode,
message: string,
details: Record<string, string[]> | null = null
): ApiError {
return { error: { code, message, details } };
}
function internalError(message: string, err: unknown) {
const body = errorBody("internal_error", message);
logApiError({ route: ROUTE, method: METHOD, body, status: 500, err });
return NextResponse.json(body, { status: 500 });
}
/** API の ISO(+09:00)→ 真の瞬間 */
function instantOrNull(iso: string | null): Date | null {
return iso === null ? null : new Date(iso);
}
/** パッチを当てた後の日時(監査ログの `*_new`)。キーが無ければ変わらない */
function nextIso(patch: PrecheckStatusPatch, key: "responded_at" | "resolved_at", before: string | null) {
if (!(key in patch) || patch[key] === undefined) return before;
const value = patch[key];
return value === null ? null : toJstIso(value);
}
/** 監査ログの共通部分(★備考の中身は載せない。`comment_changed` だけ★) */
function auditBase(
id: string,
before: PrecheckItem,
patch: PrecheckStatusPatch
): Omit<DbWriteLogEvent, "event" | "success" | "db_error_code"> {
return {
route: ROUTE,
method: METHOD,
table: "robot_error_reports",
// ★文字列のまま★(`error_id` は varchar。`Number()` を通さない。D-105 項2)
record_id: id,
columns: columnsForPatch(patch),
status_old: before.resolution_status,
status_new: patch.status ?? before.resolution_status,
responded_at_old: before.responded_at,
responded_at_new: nextIso(patch, "responded_at", before.responded_at),
resolved_at_old: before.resolved_at,
resolved_at_new: nextIso(patch, "resolved_at", before.resolved_at),
comment_changed: "note" in patch && patch.note !== undefined,
};
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
return withRequestId(request, async () => {
// 1. 形式(`error_id` は varchar(50))
const { id: idRaw } = await params;
const parsedId = parsePathErrorId(idRaw);
if (!parsedId.ok) {
return NextResponse.json(parsedId.error, { status: parsedId.status });
}
const id = parsedId.query;
// 2. 本文
let json: unknown;
try {
json = await request.json();
} catch {
return NextResponse.json(errorBody("validation_failed", "JSONの形式が不正です"), {
status: 422,
});
}
const parsedBody = parsePrecheckStatusUpdateBody(json);
if (!parsedBody.ok) {
return NextResponse.json(parsedBody.error, { status: parsedBody.status });
}
// ★現在時刻は1回だけ作る★(responded_at と resolved_at が同じ要求で別の時刻にならないように)
const now = new Date();
// 3. 実在(★参照用の接続で読む★)
let before: PrecheckItem | null;
try {
before = await db.precheckItemById(id);
} catch (e) {
return internalError("データの取得に失敗しました", e);
}
if (!before) {
return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 });
}
// 4. 計画(★何も変わらなければ書かずに現在の値を返す。ログも出さない★)
const patch = planPrecheckStatusUpdate(
{
resolution_status: before.resolution_status,
responded_at: instantOrNull(before.responded_at),
resolved_at: instantOrNull(before.resolved_at),
note: before.note,
},
parsedBody.query,
now
);
if (isEmptyPatch(patch)) {
return NextResponse.json(before);
}
// 5. 書き込み(★書き込み用の接続。updateMany の1文★)
const audit = auditBase(id, before, patch);
let matched: number;
try {
({ matched } = await db.updatePrecheckErrorColumns(id, patch));
} catch (e) {
logDbWrite({ ...audit, success: false, db_error_code: dbErrorCode(e) });
return internalError("対応状況の更新に失敗しました", e);
}
if (matched === 0) {
// 確認と書き込みの間に行が消えた(ステージングのデータ入れ替え等。第10回 N-6)
logDbWrite({ ...audit, success: false, db_error_code: "no_matching_row" });
return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 });
}
// ★書き込みが成功した時点で記録する★(読み直しの成否に関係なく、DB は変わっている)
logDbWrite({ ...audit, success: true });
// 6. 読み直し(画面はこの値でその1行を差し替える)
let after: PrecheckItem | null;
try {
after = await db.precheckItemById(id);
} catch (e) {
return internalError("データの取得に失敗しました", e);
}
if (!after) {
return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 });
}
return NextResponse.json(after);
});
}