Newer
Older
client_dwatch / lib / db / robot-error-write.ts
/**
 * `robot_error_reports` を UPDATE する唯一のファイル(指示書AT §3-2 / 段階4。指示書BD §5-4。D-184)。
 *
 * ★★メインDBへの書き込みはこのファイルを通すこと★★(D-042 / D-184 / `CLAUDE.md` §7)—
 *   書いてよい列は★経路ごとに違う★:
 *   - 事前チェック(`PATCH /prechecks/{id}/status`): ★4列★(`status` / `comment` / `responded_at` / `resolved_at`)。
 *     `robotErrorColumnsFromPatch()` / `writeRobotErrorColumns()`
 *   - エラー一覧(`PATCH /errors/{error_id}/status`。D-Sec アラートの保存も同じ経路): ★5列★(上の4列+`error_list_id`)。
 *     `robotErrorColumnsFromErrorPatch()` / `writeErrorReportColumns()`。★`error_list_id` を書けるのはこの経路だけ★
 *     (D-Sec の画面は送らない。送っても `D-S_CONTROL_FAILURE` の行は選べないのでルートが422にする)
 *   更新用アカウントは列単位の GRANT なので、許された列以外を書くと権限エラーになる。
 *   ★`error_list_id` の UPDATE 権限は未確認★(指示書BD §8。接続回復後に `SHOW GRANTS` で確かめる)。
 *
 * ★クライアントを引数で受ける★ — `prismaSource.updatePrecheckErrorColumns()` / `updateErrorReportColumns()` は
 *   書き込み用の接続(`getWritePrisma()`)を、ガードテスト(`lib/db/__tests__/robot-error-write-sql.test.ts`)は
 *   ★本物の生成クライアント+SQLを記録するだけの偽アダプタ★を渡す。★テスト用に別の書き方をしない★。
 *
 * ★★`updateMany`(1文の UPDATE)を使う。`update` を使わないこと★★(指示書AT §3-2)—
 *   `update` は SELECT → UPDATE → 全列の SELECT をトランザクションで包む(実測)。
 *   更新用アカウントに SELECT 権限があるかは分かっていない。`updateMany` なら要るのは
 *   「書く列の UPDATE」+「WHERE で読む `error_id` の SELECT」だけ。
 *
 * ★★`prisma/schema.prisma` の `RobotErrorReport.updated_at` に `@updatedAt` を付けないこと★★
 *   (指示書AT §3-1)— 付けると `updateMany` でも Prisma が `updated_at = <アプリの時刻(UTC)>` を
 *   SET に足す。DB の ON UPDATE が自分で更新する。ガードテストが見張っている。
 *
 * ★生SQL(`$executeRaw`)は使わない★(`CLAUDE.md` §3 / D-122)。
 */
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import { toDbJst } from "@/lib/format/date";
import type { PrismaClient } from "@/lib/generated/prisma/client";
import type { ErrorStatusPatch, PrecheckStatusPatch } from "@/lib/types";

/**
 * ★事前チェックの経路★(`PATCH /prechecks/{id}/status`)で書き込みを許された4列(D-042)。
 * ★ここに列を足さないこと★ — `error_list_id` は `ERROR_REPORT_WRITABLE_COLUMNS`(エラー一覧の経路だけ。D-184)
 */
export const ROBOT_ERROR_WRITABLE_COLUMNS = [
  "status",
  "comment",
  "responded_at",
  "resolved_at",
] as const;

export type RobotErrorWritableColumn = (typeof ROBOT_ERROR_WRITABLE_COLUMNS)[number];

/**
 * ★エラー一覧の経路★(`PATCH /errors/{error_id}/status`)で書き込みを許された5列(D-042 / D-184)。
 * ★5列目の `error_list_id` はこの経路だけ★(事前チェック・`robotErrorColumnsFromPatch()` は4列のまま)。
 * ★ここに列を足さないこと★(足すなら先に決定記録と `CLAUDE.md` §7 を直す)
 */
export const ERROR_REPORT_WRITABLE_COLUMNS = [...ROBOT_ERROR_WRITABLE_COLUMNS, "error_list_id"] as const;

export type ErrorReportWritableColumn = (typeof ERROR_REPORT_WRITABLE_COLUMNS)[number];

/** `updateMany` の `data` に渡す形(4列のうち変える列だけ) */
export interface RobotErrorColumnsData {
  status?: number;
  comment?: string | null;
  responded_at?: Date | null;
  resolved_at?: Date | null;
}

/** エラー一覧の経路の `data`(5列のうち変える列だけ)。`error_list_id` は bigint unsigned なので `bigint` */
export interface ErrorReportColumnsData extends RobotErrorColumnsData {
  error_list_id?: bigint;
}

/** パッチ(`PrecheckStatusPatch`)が持ってよいキー */
const PATCH_KEYS: readonly string[] = ["status", "note", "responded_at", "resolved_at"];

/** パッチ(`ErrorStatusPatch`)が持ってよいキー(★`error_list_id` はエラー一覧の経路だけ★) */
const ERROR_PATCH_KEYS: readonly string[] = [...PATCH_KEYS, "error_list_id"];

/** 書き込みに使うクライアントの最小限の形(本物の `PrismaClient` がそのまま入る) */
export type RobotErrorWriteClient = Pick<PrismaClient, "robotErrorReport">;

function dateOrNull(value: unknown, key: string): Date | null {
  if (value === null) return null;
  if (value instanceof Date && !Number.isNaN(value.getTime())) {
    // ★真の瞬間 → DB に書きたい壁時計値★(アダプタは `getUTC*` で文字列を作る。`toDbJst()` の docstring)
    return toDbJst(value);
  }
  throw new Error(`[robot-error-write] ${key} は Date か null で渡してください`);
}

/** パッチのキーが `allowed` の中だけか(★値は出さない★) */
function assertPatchKeys(patch: object, allowed: readonly string[]): void {
  for (const key of Object.keys(patch)) {
    if (!allowed.includes(key)) {
      throw new Error(`[robot-error-write] 書き込めないキーです: ${key}`);
    }
  }
}

/** 写した結果の列が `allowed` の中だけで、空でないか */
function assertDataColumns(data: object, allowed: readonly string[]): void {
  const columns = Object.keys(data);
  for (const column of columns) {
    if (!allowed.includes(column)) {
      throw new Error(`[robot-error-write] 書き込めない列です: ${column}`);
    }
  }
  if (columns.length === 0) {
    throw new Error("[robot-error-write] 書き込む列がありません(空パッチは書かないこと)");
  }
}

/** 4列ぶんの写し(キー・列の検査は呼び出し側)。★2つの経路で同じ規則を使うための内部関数★ */
function fourColumnsFromPatch(patch: PrecheckStatusPatch): RobotErrorColumnsData {
  const data: RobotErrorColumnsData = {};
  if ("status" in patch && patch.status !== undefined) {
    const status: unknown = robotErrorStatusFromResolutionStatus(patch.status);
    if (typeof status !== "number") {
      throw new Error("[robot-error-write] status の値が不正です");
    }
    data.status = status;
  }
  if ("note" in patch && patch.note !== undefined) {
    if (patch.note !== null && typeof patch.note !== "string") {
      throw new Error("[robot-error-write] note は文字列か null で渡してください");
    }
    data.comment = patch.note;
  }
  if ("responded_at" in patch && patch.responded_at !== undefined) {
    data.responded_at = dateOrNull(patch.responded_at, "responded_at");
  }
  if ("resolved_at" in patch && patch.resolved_at !== undefined) {
    data.resolved_at = dateOrNull(patch.resolved_at, "resolved_at");
  }
  return data;
}

/**
 * パッチ → `updateMany` の `data`(純粋関数)。★事前チェックの経路(4列)★。
 *
 * ★実行時の歯止め(型で防げていても残す。指示書AT 段階4-2)★:
 *   - パッチに4つ以外のキーがあれば例外(リクエスト本文のキーが DB に届く経路を作らない)。
 *     ★`error_list_id` も例外★ — 事前チェックの経路からは書かない(指示書BD §2。D-184)
 *   - 写した結果のキーが4列以外を含めば例外
 *   - 空なら例外(何も書かない要求はここまで来ない。ルートが手前で返す)
 * ★例外のメッセージに値を出さない★(備考の中身が載らないように)。
 */
export function robotErrorColumnsFromPatch(patch: PrecheckStatusPatch): RobotErrorColumnsData {
  assertPatchKeys(patch, PATCH_KEYS);
  const data = fourColumnsFromPatch(patch);
  assertDataColumns(data, ROBOT_ERROR_WRITABLE_COLUMNS);
  return data;
}

/**
 * パッチ → `updateMany` の `data`(純粋関数)。★エラー一覧の経路(5列)★(指示書BD §5-4。D-184)。
 *
 * 4列の写しは `robotErrorColumnsFromPatch()` と★同じ内部関数★。加えて `error_list_id`(1以上の安全な整数)を `bigint` にする。
 * ★実行時の歯止め(4列版と同じ)★: 5つ以外のキー → 例外 / 写した結果が5列以外を含む → 例外 / 空 → 例外 /
 *   `error_list_id` が1以上の安全な整数でない → 例外(値の検証はルートの手前で済む。ここに来たら呼び出しの誤り)。
 * ★例外のメッセージに値を出さない★。
 */
export function robotErrorColumnsFromErrorPatch(patch: ErrorStatusPatch): ErrorReportColumnsData {
  assertPatchKeys(patch, ERROR_PATCH_KEYS);
  const { error_list_id: errorListId, ...rest } = patch;
  const data: ErrorReportColumnsData = fourColumnsFromPatch(rest);
  if (errorListId !== undefined) {
    if (typeof errorListId !== "number" || !Number.isSafeInteger(errorListId) || errorListId < 1) {
      throw new Error("[robot-error-write] error_list_id は1以上の整数で渡してください");
    }
    data.error_list_id = BigInt(errorListId);
  }
  assertDataColumns(data, ERROR_REPORT_WRITABLE_COLUMNS);
  return data;
}

/** `data` のキー(=実際に SET する列)を、許可4列の並び順で返す(監査ログ用) */
export function writtenColumns(data: RobotErrorColumnsData): RobotErrorWritableColumn[] {
  return ROBOT_ERROR_WRITABLE_COLUMNS.filter((column) => column in data);
}

/** `data` のキー(=実際に SET する列)を、許可5列の並び順で返す(エラー一覧の経路の監査ログ用) */
export function writtenErrorReportColumns(data: ErrorReportColumnsData): ErrorReportWritableColumn[] {
  return ERROR_REPORT_WRITABLE_COLUMNS.filter((column) => column in data);
}

/**
 * `error_id` の1行の4カラムを `updateMany` の1文で書く(★事前チェックの経路★。`error_list_id` は書けない)。
 *
 * ★戻り値の件数で存在を判定しないこと★(指示書AT §3-2)— 存在確認は呼び出し側が書く前に
 *   参照用の接続で行う。件数が 0 なら「確認と書き込みの間に行が消えた」とみなす(ルートが 404 にする)。
 */
export async function writeRobotErrorColumns(
  client: RobotErrorWriteClient,
  errorId: string,
  patch: PrecheckStatusPatch
): Promise<{ matched: number; columns: RobotErrorWritableColumn[] }> {
  const data = robotErrorColumnsFromPatch(patch);
  const result = await client.robotErrorReport.updateMany({
    where: { error_id: errorId },
    data,
  });
  return { matched: result.count, columns: writtenColumns(data) };
}

/**
 * `error_id` の1行の5列(変える列だけ)を `updateMany` の1文で書く(★エラー一覧の経路★。指示書BD §5-4)。
 * ★`writeRobotErrorColumns()` と同じ書き方★(`updateMany` の1文・件数で存在を判定しない)。違いは写しの関数だけ。
 */
export async function writeErrorReportColumns(
  client: RobotErrorWriteClient,
  errorId: string,
  patch: ErrorStatusPatch
): Promise<{ matched: number; columns: ErrorReportWritableColumn[] }> {
  const data = robotErrorColumnsFromErrorPatch(patch);
  const result = await client.robotErrorReport.updateMany({
    where: { error_id: errorId },
    data,
  });
  return { matched: result.count, columns: writtenErrorReportColumns(data) };
}

/** パッチが SET する列(監査ログの `columns`。書く前に分かるので、失敗時のログにも載せられる) */
export function columnsForPatch(patch: PrecheckStatusPatch): RobotErrorWritableColumn[] {
  return writtenColumns(robotErrorColumnsFromPatch(patch));
}

/** エラー一覧の経路のパッチが SET する列(監査ログの `columns`) */
export function columnsForErrorPatch(patch: ErrorStatusPatch): ErrorReportWritableColumn[] {
  return writtenErrorReportColumns(robotErrorColumnsFromErrorPatch(patch));
}