Newer
Older
client_dwatch / app / api / errors / [error_id] / status / route.ts
/**
 * PATCH /errors/{error_id}/status — エラー一覧の対応状況・備考を更新する(D-160 / 指示書AW 段階4-3)
 *
 * 仕様の正は `d-watch-api.yaml` の /errors/{error_id}/status。
 *
 * ★`app/api/prechecks/[id]/status/route.ts` と同じ順序・同じ部品★(形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し)。
 *   違いは ★`robot_error_reports` に `error_id` の行があれば書ける(事前チェックかどうかを問わない)★ことだけ
 *   (実在は `db.errorReportById()`、書き込みは `db.updateErrorReportColumns()`)。
 *   ★`/prechecks` のルートとは共通化しない★(指示書AW D12。`/prechecks` の挙動とテストを巻き込まない)—
 *   同じ形の小さな関数をこのファイルに置く。
 *
 * ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の4列(`status` / `comment` / `responded_at` / `resolved_at`)だけ
 *   (D-042 / `CLAUDE.md` §7)。★書き込みの中身は `lib/db/robot-error-write.ts`★、★パッチを作るのは
 *   `planPrecheckStatusUpdate()` だけ★(リクエスト本文のキーが DB に届く経路を作らない。同じ列を2つの規則で書かない。D11)。
 *
 * ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。
 * ★備考の中身は載せない★(`comment_changed` だけ。`docs/logging-design.md` の禁止キー)。
 */
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, ErrorReportItem, PrecheckStatusPatch } from "@/lib/types";

const ROUTE = "/api/errors/[error_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 });
}

function notFound() {
  return NextResponse.json(errorBody("not_found", NOT_FOUND_MESSAGE), { status: 404 });
}

/** 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: ErrorReportItem,
  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<{ error_id: string }> }
) {
  return withRequestId(request, async () => {
    // 1. 形式(`error_id` は varchar(50))
    const { error_id: idRaw } = await params;
    const parsedId = parsePathErrorId(idRaw);
    if (!parsedId.ok) {
      return NextResponse.json(parsedId.error, { status: parsedId.status });
    }
    const id = parsedId.query;

    // 2. 本文(★`/prechecks/{id}/status` と同じ検証★)
    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. 実在(★参照用の接続で読む★。★大小文字は `===` で決める(D-4)★)
    let before: ErrorReportItem | null;
    try {
      before = await db.errorReportById(id);
    } catch (e) {
      return internalError("データの取得に失敗しました", e);
    }
    if (!before) return notFound();

    // 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.updateErrorReportColumns(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 notFound();
    }
    // ★書き込みが成功した時点で記録する★(読み直しの成否に関係なく、DB は変わっている)
    logDbWrite({ ...audit, success: true });

    // 6. 読み直し(画面はこの値でその1行を差し替える)
    let after: ErrorReportItem | null;
    try {
      after = await db.errorReportById(id);
    } catch (e) {
      return internalError("データの取得に失敗しました", e);
    }
    if (!after) return notFound();
    return NextResponse.json(after);
  });
}