Newer
Older
client_dwatch / app / api / errors / [error_id] / status / route.ts
/**
 * PATCH /errors/{error_id}/status — エラー一覧の対応状況・備考・エラー内容を更新する(D-160 / 指示書AW 段階4-3 / 指示書BD §3-3。D-184)
 *
 * 仕様の正は `d-watch-api.yaml` の /errors/{error_id}/status。
 *
 * ★`app/api/prechecks/[id]/status/route.ts` と同じ順序★(形式 → 本文 → 実在 → 計画 → 書き込み → 読み直し)。
 *   ★違いの1つ目は `error_list_id`(エラー内容)を書けること★(D-184)— 本文・計画は `lib/errors/status-update.ts`
 *   (事前チェックの規則の上に `error_list_id` を足したもの)、計画の途中で有効な選択肢を `db.errorLists()` で引く(必要なときだけ)。
 *   違いの2つ目は ★`robot_error_reports` に `error_id` の行があれば書ける(事前チェックかどうかを問わない)★こと。★ただし削除済みの施設・清掃エリアの行は `db.errorReportById()` が null を返すので404(指示書AX U8。このルートのコードは変えていない)★
 *   (実在は `db.errorReportById()`、書き込みは `db.updateErrorReportColumns()`)。
 *   ★`/prechecks` のルートとは共通化しない★(指示書AW D12。`/prechecks` の挙動とテストを巻き込まない)—
 *   同じ形の小さな関数をこのファイルに置く。
 *
 * ★★メインDBへの書き込み★★ — 書くのは `robot_error_reports` の5列(`status` / `comment` / `responded_at` / `resolved_at` /
 *   `error_list_id`)だけ(D-042 / D-184 / `CLAUDE.md` §7)。★5列目の `error_list_id` を書けるのはこの経路だけ★(`/prechecks` は4列)。
 *   ★書き込みの中身は `lib/db/robot-error-write.ts`(`writeErrorReportColumns()`)★、★パッチを作るのは
 *   `planErrorStatusUpdate()` だけ★(リクエスト本文のキーが DB に届く経路を作らない。同じ列を2つの規則で書かない。D11)。
 *
 * ★監査ログ(`db.write`)は成功・失敗で1件ずつ★。空パッチ(何も変わらない要求)では書かないので出さない。
 * ★備考の中身は載せない★(`comment_changed` だけ。`docs/logging-design.md` の禁止キー)。
 * ★エラー内容は id の前後だけ★(`error_list_id_old` / `error_list_id_new`。分類名・詳細の文字列は載せない)。
 */
import { NextResponse } from "next/server";
import { parsePathErrorId } from "@/lib/api/path-params";
import { db } from "@/lib/db";
import { columnsForErrorPatch } from "@/lib/db/robot-error-write";
import {
  errorListIdToLookUp,
  isEmptyErrorPatch,
  parseErrorStatusUpdateBody,
  planErrorStatusUpdate,
} from "@/lib/errors/status-update";
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 type { ApiError, ErrorCode, ErrorReportItem, ErrorStatusPatch } 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: ErrorStatusPatch, 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: ErrorStatusPatch
): 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: columnsForErrorPatch(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),
    error_list_id_old: before.error_list_id,
    error_list_id_new: patch.error_list_id ?? before.error_list_id,
    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. 本文(★status / note は `/prechecks/{id}/status` と同じ検証★。加えて error_list_id の型)
    let json: unknown;
    try {
      json = await request.json();
    } catch {
      return NextResponse.json(errorBody("validation_failed", "JSONの形式が不正です"), {
        status: 422,
      });
    }
    const parsedBody = parseErrorStatusUpdateBody(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. 計画(★検証に落ちたら422・何も変わらなければ書かずに現在の値を返す。どちらもログを出さない★)
    //   ★error_list_id の現在値と「選べるか」は `errorReportById()` の結果から取る★(別のクエリを撃たない)
    const current = {
      resolution_status: before.resolution_status,
      responded_at: instantOrNull(before.responded_at),
      resolved_at: instantOrNull(before.resolved_at),
      note: before.note,
      error_list_id: before.error_list_id,
      error_list_selectable: before.error_list_selectable,
    };
    // ★有効な選択肢は、id が変わり・選べる行のときだけ引く★(指示書BD §3-3 手順3。`db.errorLists()` は有効な行だけを返す)
    let activeErrorListIds: ReadonlySet<number> = new Set();
    if (errorListIdToLookUp(current, parsedBody.query) !== null) {
      try {
        activeErrorListIds = new Set((await db.errorLists()).map((item) => item.id));
      } catch (e) {
        return internalError("データの取得に失敗しました", e);
      }
    }
    const planned = planErrorStatusUpdate(current, parsedBody.query, now, (errorListId) =>
      activeErrorListIds.has(errorListId)
    );
    if (!planned.ok) {
      return NextResponse.json(planned.error, { status: planned.status });
    }
    const patch = planned.query;
    if (isEmptyErrorPatch(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);
  });
}