Newer
Older
client_dwatch / lib / db / robot-error-write.ts
/**
 * `robot_error_reports` の4カラム(`status` / `comment` / `responded_at` / `resolved_at`)を
 * UPDATE する唯一の関数(指示書AT §3-2 / 段階4)。
 *
 * ★★メインDBへの書き込みはこのファイルを通すこと★★(D-042 / `CLAUDE.md` §7)—
 *   書いてよいのは上の4列だけ。更新用アカウントは列単位の GRANT なので、5列目を書くと権限エラーになる。
 *
 * ★クライアントを引数で受ける★ — `prismaSource.updatePrecheckErrorColumns()` は書き込み用の接続
 *   (`getWritePrisma()`)を、ガードテスト(`lib/db/__tests__/robot-error-write-sql.test.ts`)は
 *   ★本物の生成クライアント+SQLを記録するだけの偽アダプタ★を渡す。★テスト用に別の書き方をしない★。
 *
 * ★★`updateMany`(1文の UPDATE)を使う。`update` を使わないこと★★(指示書AT §3-2)—
 *   `update` は SELECT → UPDATE → 全列の SELECT をトランザクションで包む(実測)。
 *   更新用アカウントに SELECT 権限があるかは分かっていない。`updateMany` なら要るのは
 *   「4列の 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 { PrecheckStatusPatch } from "@/lib/types";

/** 書き込みを許された4列(D-042)。★ここに列を足さないこと★ */
export const ROBOT_ERROR_WRITABLE_COLUMNS = [
  "status",
  "comment",
  "responded_at",
  "resolved_at",
] as const;

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

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

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

/** 書き込みに使うクライアントの最小限の形(本物の `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 で渡してください`);
}

/**
 * パッチ → `updateMany` の `data`(純粋関数)。
 *
 * ★実行時の歯止め(型で防げていても残す。指示書AT 段階4-2)★:
 *   - パッチに4つ以外のキーがあれば例外(リクエスト本文のキーが DB に届く経路を作らない)
 *   - 写した結果のキーが4列以外を含めば例外
 *   - 空なら例外(何も書かない要求はここまで来ない。ルートが手前で返す)
 * ★例外のメッセージに値を出さない★(備考の中身が載らないように)。
 */
export function robotErrorColumnsFromPatch(patch: PrecheckStatusPatch): RobotErrorColumnsData {
  for (const key of Object.keys(patch)) {
    if (!PATCH_KEYS.includes(key)) {
      throw new Error(`[robot-error-write] 書き込めないキーです: ${key}`);
    }
  }

  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");
  }

  const columns = Object.keys(data);
  for (const column of columns) {
    if (!(ROBOT_ERROR_WRITABLE_COLUMNS as readonly string[]).includes(column)) {
      throw new Error(`[robot-error-write] 書き込めない列です: ${column}`);
    }
  }
  if (columns.length === 0) {
    throw new Error("[robot-error-write] 書き込む列がありません(空パッチは書かないこと)");
  }
  return data;
}

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

/**
 * `error_id` の1行の4カラムを `updateMany` の1文で書く。
 *
 * ★戻り値の件数で存在を判定しないこと★(指示書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) };
}

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