Newer
Older
client_dwatch / lib / settings / advice.ts
import path from "node:path";
import {
  DEFAULT_ADVICE_RULES,
  type AdviceRuleDefinition,
  type AdviceRuleId,
  type EditableAdviceFields,
} from "@/lib/config/advice-rules";
import { logEvent } from "@/lib/logger";
import type { SettingsStoreCorruptLogEvent } from "@/lib/logger/events";
import {
  createJsonStore,
  type JsonStore,
  type JsonStoreWarning,
} from "@/lib/settings/json-store";

/**
 * アドバイス文言の運用設定
 *
 * システム管理 →「運用設定」から編集された文言を保持します。
 * 保存先は `data/settings/advice.json`。**メインDBには保存しません**
 * (DBはTeamWorks管理で読み取り専用のため。CLAUDE.md 7. 禁止事項)。
 *
 * 保存されているのは編集された項目だけで、未編集のルールは
 * `lib/config/advice-rules.ts` の既定値がそのまま使われます。
 * これにより、既定値を後から改善したときに、編集していないルールへ自動で反映されます。
 *
 * ファイルの読み書き(アトミック差し替え・破損時の `.bak` 退避)は
 * `lib/settings/json-store.ts` に寄せてあります。ここに残すのは
 * 「既定値へ編集内容を重ねる」というアドバイス固有の合成ロジックだけです。
 */

/** ファイルに入るのは編集された項目だけ。未編集のルールはキーごと存在しません */
export type AdviceOverrides = Partial<
  Record<AdviceRuleId, Partial<EditableAdviceFields>>
>;

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

/**
 * 破損検知を `settings.store_corrupt` として1行残す。
 *
 * `json-store.ts` は `lib/logger` を import しない方針(依存グラフを重くしないため)なので、
 * `logEvent` を呼ぶのは利用側であるここの仕事です。
 * `route` / `method` は載せません。`loadAdviceRules()` は
 * `/api/settings/advice` と `/api/clean-logs/[id]/advice` の2ルートから共有されており、
 * どちらが呼んだかを公開シグネチャを変えずには知れないためです(不明な情報は書かない)。
 */
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);
}

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

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

function adviceStore(): JsonStore<AdviceOverrides> {
  adviceStoreInstance ??= createAdviceStore();
  return adviceStoreInstance;
}

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

/** 既定値に編集内容を重ねた、実際に表示するルール定義を返す */
export async function loadAdviceRules(): Promise<
  Record<AdviceRuleId, AdviceRuleDefinition>
> {
  const overrides = await adviceStore().read();
  const merged = {} as Record<AdviceRuleId, AdviceRuleDefinition>;

  (Object.keys(DEFAULT_ADVICE_RULES) as AdviceRuleId[]).forEach((id) => {
    const base = DEFAULT_ADVICE_RULES[id];
    const ov = overrides[id];
    merged[id] = ov ? { ...base, ...ov } : base;
  });

  return merged;
}

/** 1ルール分の文言を保存する。level / icon / condition は編集不可 */
export async function saveAdviceRule(
  id: AdviceRuleId,
  fields: Partial<EditableAdviceFields>
): Promise<AdviceRuleDefinition> {
  assertKnownRule(id);

  const store = adviceStore();
  // 全件読んでから1件だけ差し替えて書き戻す。他ルールの編集を巻き込んで消さないため
  const overrides = await store.read();
  overrides[id] = { ...overrides[id], ...fields };
  await store.write(overrides);

  return { ...DEFAULT_ADVICE_RULES[id], ...overrides[id] };
}

/** 1ルール分の編集を取り消して既定値に戻す */
export async function resetAdviceRule(
  id: AdviceRuleId
): Promise<AdviceRuleDefinition> {
  assertKnownRule(id);

  const store = adviceStore();
  const overrides = await store.read();
  delete overrides[id];
  await store.write(overrides);
  return DEFAULT_ADVICE_RULES[id];
}

/**
 * 未知のルールIDを弾く。
 *
 * 現状は `app/api/settings/advice/route.ts` がルート側で 422 を返すため API からは到達しませんが、
 * ライブラリを直接呼ぶコードが増えたときに「知らないIDが `advice.json` に書き込まれ、
 * 以後どの画面からも消せないゴミになる」のを防ぎます。
 * save / reset のどちらか片方にだけチェックがある状態は非対称なので揃えてあります。
 */
function assertKnownRule(id: AdviceRuleId): void {
  if (!DEFAULT_ADVICE_RULES[id]) {
    throw new Error(`未知のアドバイスルールです: ${id}`);
  }
}