Newer
Older
client_dwatch / lib / settings / ds-device-notes.ts
import path from "node:path";
import { logEvent } from "@/lib/logger";
import type { SettingsStoreCorruptLogEvent } from "@/lib/logger/events";
import {
  createJsonStore,
  type JsonStore,
  type JsonStoreWarning,
} from "@/lib/settings/json-store";

/**
 * D-Sデバイスの備考(運用担当の手入力メモ)
 *
 * D-Secログ画面(F-06)の「備考」列に運用担当が入力した文言を保持します。
 * 保存先は `data/settings/ds-device-notes.json`。**メインDBには保存しません**
 * (DBはTeamWorks管理で読み取り専用のため。CLAUDE.md 7. 禁止事項。
 * 更新が許されているのは `robot_error_reports` / `control_error_histories` の
 * `status` / `comment` / `responded_at` / `resolved_at` の4カラムだけで、
 * デバイスの備考はそのいずれでもありません)。
 *
 * ★`/data` は .gitignore されています★=この JSON は**コミットされず、git の
 * バックアップ対象外**です。唯一の実体がサーバ上のこのファイルなので、
 * 破損時は上書きせず `.bak` へ退避する(`json-store.ts` の責務)ことが前提になります。
 *
 * ★ロックは入れません★ — 施設20件弱・実質1人の運用で、`json-store.ts` の
 * rename によるアトミック差し替えで「壊れたファイルが残る」ことは防げるためです。
 * 同時保存は後勝ちになりますが、その代償は「片方の備考が1件巻き戻る」だけで、
 * ロック機構(とその解放漏れ)を持ち込むより安全という判断です。
 * 利用者が増えたら `json-store.ts` 側にロックを入れること(ここは変えずに済みます)。
 *
 * ★将来 SQLite へ移すときは、このファイルだけ差し替えれば済みます★。
 * 呼び出し側(APIルート・画面)が知っているのは `readDeviceNotes()` /
 * `saveDeviceNote()` という2つの関数だけで、保存形式が JSON1ファイルなのか
 * テーブルなのかは公開シグネチャに現れません。点検記録ストア(SQLite)へ
 * 相乗りさせる判断になっても、置き換えの影響範囲はこのモジュールに閉じます。
 */

/**
 * `serial_number` → 備考。
 *
 * 備考が空のデバイスは**キーごと存在しません**(空文字を値として持たない)。
 * 「未入力」と「空文字を入力した」を区別しないことで、
 * 消したはずの備考がファイルに残り続けるのを防ぎます。
 */
export type DeviceNotes = Record<string, string>;

/**
 * 保存先の既定パス。
 *
 * ★モジュール読み込み時ではなく呼び出し時に組み立てます★。
 * トップレベルの `const` で固定すると `process.cwd()` が import 順に依存し、
 * かつ後から差し替える手段が無くなります(テストが実ファイルを汚す)。
 */
function defaultDeviceNotesStorePath(): string {
  return path.join(process.cwd(), "data", "settings", "ds-device-notes.json");
}

/**
 * 破損検知を `settings.store_corrupt` として1行残す。
 *
 * `json-store.ts` は `lib/logger` を import しない方針(依存グラフを重くしないため)なので、
 * `logEvent` を呼ぶのは利用側であるここの仕事です。
 * `route` / `method` は載せません。読み書きの入口が複数のルートから共有されるため、
 * どちらが呼んだかを公開シグネチャを変えずには知れないためです(不明な情報は書かない)。
 */
function reportCorruptStore(info: JsonStoreWarning): void {
  const event: SettingsStoreCorruptLogEvent = {
    event: "settings.store_corrupt",
    file_path: info.filePath,
    backup_path: info.backupPath,
    reason: info.reason,
  };
  logEvent("warn", event);
}

/**
 * D-Sデバイス備考用のストアを1つ作る。
 *
 * ★引数を両方とも省略可能にしてあるのは、テストから保存先と警告先を差し替えるため★。
 * 本番コードは引数なしで呼び、既定パス + `logEvent` の組み合わせになります。
 */
export function createDeviceNotesStore(
  filePath: string = defaultDeviceNotesStorePath(),
  onWarn: (info: JsonStoreWarning) => void = reportCorruptStore
): JsonStore<DeviceNotes> {
  return createJsonStore<DeviceNotes>({
    filePath,
    // `json-store.ts` の契約。プロトタイプ汚染対策の土台になる
    empty: () => Object.create(null) as DeviceNotes,
    onWarn,
  });
}

/**
 * モジュール内で共有するストア。
 *
 * `let` + 遅延生成にしてあるのは、`readDeviceNotes()` 等の**公開シグネチャを変えずに**
 * 保存先を差し替えられるようにするためです(引数でパスを引き回すと呼び出し元が
 * 巻き込まれ、APIルートが知らなくてよいことを知ることになる)。
 * `advice.ts` と同じ形にしてあります。
 */
let deviceNotesStoreInstance: JsonStore<DeviceNotes> | null = null;

function deviceNotesStore(): JsonStore<DeviceNotes> {
  deviceNotesStoreInstance ??= createDeviceNotesStore();
  return deviceNotesStoreInstance;
}

/**
 * ★テスト専用★。本番コードからは呼びません。
 * `null` を渡すと既定(`data/settings/ds-device-notes.json`)へ戻ります。
 */
export function __setDeviceNotesStoreForTesting(
  store: JsonStore<DeviceNotes> | null
): void {
  deviceNotesStoreInstance = store;
}

/**
 * 保存済みの備考を全件返す。
 *
 * 一度も保存していない(=ファイルが無い)場合も例外にせず空を返します。
 * 備考の未入力は正常な状態で、画面はそのまま空欄を描けばよいためです。
 */
export async function readDeviceNotes(): Promise<DeviceNotes> {
  return deviceNotesStore().read();
}

/**
 * 1デバイス分の備考を保存する。戻り値は保存後の値(削除したときは `null`)。
 *
 * ★トリム後の値を保存し、トリム後が空文字ならキーごと削除します★。
 * 前後の空白だけを入力するのは「消したい」という意図とみなす方が
 * 運用の実態に合っており、また「見た目は空欄なのにキーが残る」状態を作らないためです。
 * トリム前の値をそのまま保存すると、次に読んだとき画面上は空欄に見えるのに
 * 削除もされていない、という説明のつかない状態になります。
 */
export async function saveDeviceNote(
  serial: string,
  note: string
): Promise<string | null> {
  const store = deviceNotesStore();
  // 全件読んでから1件だけ差し替えて書き戻す。他デバイスの備考を巻き込んで消さないため
  const notes = await store.read();

  const trimmed = note.trim();
  if (trimmed === "") {
    delete notes[serial];
    await store.write(notes);
    return null;
  }

  notes[serial] = trimmed;
  await store.write(notes);
  return trimmed;
}