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