/**
* `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));
}