import path from "node:path";
import { CONSECUTIVE_DAYS_DISPLAY } from "@/lib/config";
import { logEvent } from "@/lib/logger";
import type {
SettingsStoreCorruptLogEvent,
SettingsValueInvalidLogEvent,
} from "@/lib/logger/events";
import {
createJsonStore,
type JsonStore,
type JsonStoreWarning,
} from "@/lib/settings/json-store";
import type { DisplayLimitKey, DisplayLimitSetting } from "@/lib/types";
/**
* 表示上限の運用設定(指示書AF 段階3 / ユーザー指示 2026-09-13)
*
* システム管理 →「運用設定」から編集された表示上限を保持します。
* 保存先は `data/settings/display-limits.json`。**メインDBには保存しません**
* (DBはTeamWorks管理で読み取り専用のため。CLAUDE.md §3 / §7 禁止事項)。
*
* ★これは「表示のための上限」であって判定の閾値ではありません★ —
* L1〜L3(`lib/judge/escalation.ts`)と R1〜R4(`lib/judge/advice.ts`)は
* ★上限をかける前の生値★で判定します(`CLAUDE.md` §4.3 と同じ思想)。
*
* 保存されているのは編集されたキーだけで、未編集のキーは
* `lib/config/index.ts` の `CONSECUTIVE_DAYS_DISPLAY` の既定値がそのまま使われます。
*
* ファイルの読み書き(アトミック差し替え・破損時の `.bak` 退避・プロトタイプ汚染対策)は
* `lib/settings/json-store.ts` に寄せてあります(`lib/settings/advice.ts` と同じ構造)。
* ★新しい保存の仕組みを作らないこと★(指示書AF §A-3)。
*/
/** ファイルに入るのは編集されたキーだけ。未編集のキーは存在しません */
export interface DisplayLimitOverrides {
consecutive_days_max?: number;
}
/** ストアに入っているキー名。ログにもそのまま載せる */
const CONSECUTIVE_DAYS_MAX_KEY = "consecutive_days_max";
/**
* 保存先の既定パス。
*
* ★モジュール読み込み時ではなく呼び出し時に組み立てます★(`advice.ts` と同じ理由)。
* トップレベルの `const` で固定すると `process.cwd()` が import 順に依存し、
* かつ後から差し替える手段が無くなります(テストが実ファイルを汚す)。
*/
function defaultDisplayLimitStorePath(): string {
return path.join(process.cwd(), "data", "settings", "display-limits.json");
}
/**
* 破損検知を `settings.store_corrupt` として1行残す。
*
* `json-store.ts` は `lib/logger` を import しない方針(依存グラフを重くしないため)なので、
* `logEvent` を呼ぶのは利用側であるここの仕事です。
* `route` / `method` は載せません。`loadConsecutiveDaysMax()` は複数のルートから
* 共有されており、どちらが呼んだかを公開シグネチャを変えずには知れないためです。
*/
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 createDisplayLimitStore(
filePath: string = defaultDisplayLimitStorePath(),
onWarn: (info: JsonStoreWarning) => void = reportCorruptStore
): JsonStore<DisplayLimitOverrides> {
return createJsonStore<DisplayLimitOverrides>({
filePath,
// `json-store.ts` の契約。プロトタイプ汚染対策の土台になる
empty: () => Object.create(null) as DisplayLimitOverrides,
onWarn,
});
}
/**
* モジュール内で共有するストア。
*
* `let` + 遅延生成にしてあるのは、`loadConsecutiveDaysMax()` 等の**公開シグネチャを変えずに**
* 保存先を差し替えられるようにするためです(引数でパスを引き回すと、上限を使う
* 4つのAPIルートが「設定ファイルの場所」という知らなくてよいことを知ることになる)。
*/
let displayLimitStoreInstance: JsonStore<DisplayLimitOverrides> | null = null;
/**
* ログに載せる保存先のパス。
*
* `JsonStore` はパスを外から読めない(`json-store.ts` の公開契約は read / write だけ)ので、
* ★差し替えたときのパスをここに控えます★。控えないと、テストが一時ディレクトリへ
* 差し替えているのにログには既定パスが出る=★事実と違うパスを記録する★ことになります。
*/
let displayLimitStorePath: string | null = null;
function displayLimitStore(): JsonStore<DisplayLimitOverrides> {
displayLimitStoreInstance ??= createDisplayLimitStore();
return displayLimitStoreInstance;
}
/** ログ用。差し替えられていなければ既定パス */
function currentStorePath(): string {
return displayLimitStorePath ?? defaultDisplayLimitStorePath();
}
/**
* ★テスト専用★。本番コードからは呼びません。
* `null` を渡すと既定(`data/settings/display-limits.json`)へ戻ります。
* `filePath` はログに載せる保存先。★`store` と同じパスを渡すこと★。
*/
export function __setDisplayLimitStoreForTesting(
store: JsonStore<DisplayLimitOverrides> | null,
filePath: string | null = null
): void {
displayLimitStoreInstance = store;
displayLimitStorePath = store === null ? null : filePath;
}
export type ValidationOutcome =
| { ok: true; value: number }
| { ok: false; reason: string };
/**
* 表示上限として保存してよい値か判定する。★純粋関数★(ファイルを読まない)。
*
* ★ここが検証の唯一の正★ — PATCH ルートも、保存済みの値の読み込み時も、
* 両方ここを通します。★画面側の `<input min max>` は補助であって検証ではありません★
* (開発者ツールで外せる。`CLAUDE.md` §7 / 指示書AF 禁止事項8)。
*
* ★`Number.isInteger(1e9)` は `true`★ です。巨大値は範囲の条件(`maxMax`)で弾きます
* (整数判定では弾けません。指示書AF §G 落とし穴6)。
*
* ★`reason` に入力値をエコーしないこと★ — そのまま API の `details` に載り、
* 画面へ返ります(`advice-validation.ts` と同じ規律)。
*/
export function validateConsecutiveDaysMax(value: unknown): ValidationOutcome {
if (typeof value !== "number") {
return { ok: false, reason: "数値で指定してください" };
}
// NaN / Infinity / -Infinity。★`Number.isInteger` より先に見る★
// (`Number.isInteger(Infinity)` は false なので順序を入れ替えても弾けるが、
// 理由の分類が「整数でない」になり、原因が分かりづらくなる)
if (!Number.isFinite(value)) {
return { ok: false, reason: "有限の数値で指定してください" };
}
if (!Number.isInteger(value)) {
return { ok: false, reason: "整数で指定してください" };
}
// ★数値を直書きしないこと★(`CLAUDE.md` §7)。下限は L3 の日数から導いてある
if (value < CONSECUTIVE_DAYS_DISPLAY.minMax) {
return {
ok: false,
reason: `${CONSECUTIVE_DAYS_DISPLAY.minMax}以上で指定してください`,
};
}
if (value > CONSECUTIVE_DAYS_DISPLAY.maxMax) {
return {
ok: false,
reason: `${CONSECUTIVE_DAYS_DISPLAY.maxMax}以下で指定してください`,
};
}
return { ok: true, value };
}
/**
* 保存されていた値が検証で弾かれた事実を `settings.value_invalid` として1行残す。
*
* ★黙って既定値に倒れると、運用担当には「保存したのに効かない」としか分かりません★。
* ★`settings.store_corrupt` を流用しないこと★ — あちらは `.bak` へ退避した事実の記録で、
* ここでは退避していません(退避していないのに退避したと書かない)。
*/
function reportInvalidValue(
filePath: string,
rawValue: unknown,
reason: string
): void {
const event: SettingsValueInvalidLogEvent = {
event: "settings.value_invalid",
file_path: filePath,
setting_key: CONSECUTIVE_DAYS_MAX_KEY,
value_type: describeType(rawValue),
reason,
};
// ★数値のときだけ値そのものを載せる★ — 数値1つは自由記述ではないので
// 「ログに本文を載せない」規律に触れない。文字列等は型名だけにする
// (手で編集されたファイルには自由記述が入りうる)
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
event.invalid_value = rawValue;
}
logEvent("warn", event);
}
function describeType(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}
/**
* 現在有効な連続日数の表示上限を返す。
*
* ★次の4つすべてで既定値に倒れます(例外にしません)★ —
* システム管理画面が開かなくなるより、既定値で開くほうがよいため:
* 1. ファイルが無い(一度も保存していない)
* 2. JSON として壊れている(`json-store.ts` が `.bak` へ退避して空を返す)
* 3. JSON は読めるが `consecutive_days_max` が無い
* 4. ★値は入っているが不正(文字列 / 小数 / 範囲外)★
* → ★読み込み時にも `validateConsecutiveDaysMax()` を通す★。
* 手でファイルを編集された場合や、将来 `minMax` / `maxMax` を狭めた場合に
* 保存済みの値が範囲外になるため(指示書AF 段階3-3)
*/
export async function loadConsecutiveDaysMax(): Promise<number> {
const store = displayLimitStore();
const overrides = await store.read();
const raw: unknown = overrides[CONSECUTIVE_DAYS_MAX_KEY];
// 未編集は正常な状態。ログも出さない
if (raw === undefined) return CONSECUTIVE_DAYS_DISPLAY.defaultMax;
const outcome = validateConsecutiveDaysMax(raw);
if (!outcome.ok) {
reportInvalidValue(currentStorePath(), raw, outcome.reason);
return CONSECUTIVE_DAYS_DISPLAY.defaultMax;
}
return outcome.value;
}
/**
* `GET /settings/display-limits` が返す1件(連続日数の表示上限)。
*
* ★`min` / `max` は検証範囲そのものを返すこと★ — 画面の入力欄がこの値を使うため、
* ここでずらすと「画面では入れられるのにサーバが422を返す」状態になります。
*/
async function consecutiveDaysMaxSetting(): Promise<DisplayLimitSetting> {
const value = await loadConsecutiveDaysMax();
return {
key: CONSECUTIVE_DAYS_MAX_KEY,
value,
default: CONSECUTIVE_DAYS_DISPLAY.defaultMax,
min: CONSECUTIVE_DAYS_DISPLAY.minMax,
max: CONSECUTIVE_DAYS_DISPLAY.maxMax,
// 不正な値で既定値に倒れたときは「編集済み」にしない(実際に効いていないため)
customized: value !== CONSECUTIVE_DAYS_DISPLAY.defaultMax,
};
}
/**
* 連続日数の表示上限を保存する。
*
* ★保存前にも検証します★ — ルート側の検証を通っていることが前提ですが、
* ライブラリを直接呼ぶコードが増えたときに、範囲外の値がファイルに書き込まれ
* 「画面で直そうとしても入力欄の `min` / `max` に阻まれて直せない」状態になるのを防ぎます
* (`advice.ts` の `assertKnownRule()` と同じ考え方)。
*/
export async function saveConsecutiveDaysMax(value: number): Promise<number> {
const outcome = validateConsecutiveDaysMax(value);
if (!outcome.ok) {
throw new Error(`連続日数の表示上限として保存できない値です: ${outcome.reason}`);
}
const store = displayLimitStore();
// 全件読んでから1キーだけ差し替えて書き戻す。将来キーが増えたときに巻き込んで消さないため
const overrides = await store.read();
overrides[CONSECUTIVE_DAYS_MAX_KEY] = outcome.value;
await store.write(overrides);
return outcome.value;
}
/** 編集を取り消して既定値に戻す。キーごと消す(既定値をコピーして持たない) */
export async function resetConsecutiveDaysMax(): Promise<number> {
const store = displayLimitStore();
const overrides = await store.read();
delete overrides[CONSECUTIVE_DAYS_MAX_KEY];
await store.write(overrides);
return CONSECUTIVE_DAYS_DISPLAY.defaultMax;
}
// ------------------------------------------------ キーごとの振り分け(★ここが唯一の入口★)
/**
* 表示上限1キー分の処理。★キーが増えたら、その分ここを1組そろえる★
*
* 検証・保存・リセット・GET の1件がバラバラの場所にあると、
* 「キーを足したのに保存だけ前のキーのまま」という組み合わせが作れてしまいます。
* 1キー = 1オブジェクトにまとめて、そろっていないと型が通らない形にしてあります。
*/
interface DisplayLimitHandler {
/** 保存してよい値かを判定する(★純粋関数。ファイルを読まない★) */
validate: (value: unknown) => ValidationOutcome;
/** 検証済みの値を保存し、実際に有効になった値を返す */
save: (value: number) => Promise<number>;
/** 編集を取り消して既定値に戻し、既定値を返す */
reset: () => Promise<number>;
/** `GET /settings/display-limits` が返す1件 */
setting: () => Promise<DisplayLimitSetting>;
}
/**
* ★★キーごとの処理の対応表。表示上限のキーを知っているのはここ**だけ**★★
*
* ★★`Record<DisplayLimitKey, DisplayLimitHandler>` にしてあるのは、
* 「キーを足したのに処理を足していない」を★コンパイルエラーにする★ためです★★ —
* `lib/types/index.ts` の `DisplayLimitKey` に2つ目のキーを足して
* この表に足さないと、★`npx tsc --noEmit` が
* 「型 '...' には ... からの次のプロパティがありません」で落ちます★。
* ★テストでは表現できない保証なので、ここと
* `lib/settings/__tests__/display-limits.test.ts` にコメントで残しています★。
*
* ★なぜ必要か(指示書AF §H-1 の「他の閾値へ広げる」を安全にするため)★:
* 以前は API ルート側の `KNOWN_KEYS` だけが「複数キー」の形で、
* 保存・リセット・取得は `consecutive_days_max` に固定されていました。
* その状態で2つ目のキーを足すと、★別キーへの PATCH が
* `consecutive_days_max` を書き換えます★。例外もテスト失敗も出ません
* (「変えたはずの設定が変わらず、関係ない設定が変わる」)。
*
* ★`lib/settings/advice.ts` の `DEFAULT_ADVICE_RULES`(`Record<AdviceRuleId, …>`)と
* 同じ作法です。新しい保存の仕組みは作っていません(指示書AF §A-3)★。
*/
const DISPLAY_LIMIT_HANDLERS: Record<DisplayLimitKey, DisplayLimitHandler> = {
consecutive_days_max: {
validate: validateConsecutiveDaysMax,
save: saveConsecutiveDaysMax,
reset: resetConsecutiveDaysMax,
setting: consecutiveDaysMaxSetting,
},
};
/**
* 実行時に未知のキーを弾く型ガード。API ルートはこれが `false` のとき 422 を返します。
*
* ★`hasOwnProperty` で見ること★ — `key in obj` や `HANDLERS[key]` だと
* `"toString"` / `"constructor"` のようなプロトタイプのプロパティに当たり、
* 未知のキーを「知っているキー」と誤認します(`json-store.ts` と同じ警戒)。
*/
export function isDisplayLimitKey(value: unknown): value is DisplayLimitKey {
return (
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(DISPLAY_LIMIT_HANDLERS, value)
);
}
/** キーに対応する検証を通す。★純粋関数★(ファイルを読まない) */
export function validateDisplayLimit(
key: DisplayLimitKey,
value: unknown
): ValidationOutcome {
return DISPLAY_LIMIT_HANDLERS[key].validate(value);
}
/** キーに対応する保存を行う。★検証を通した値だけを渡すこと★(保存側でも再検証します) */
export async function saveDisplayLimit(
key: DisplayLimitKey,
value: number
): Promise<number> {
return DISPLAY_LIMIT_HANDLERS[key].save(value);
}
/** キーに対応する編集を取り消して既定値に戻す */
export async function resetDisplayLimit(key: DisplayLimitKey): Promise<number> {
return DISPLAY_LIMIT_HANDLERS[key].reset();
}
/**
* `GET /settings/display-limits` が返す一覧。★表に載っているキーを全件返す★
*
* 表から作るので、キーを足したときに一覧へ載せ忘れることがありません
* (`Object.keys()` の `as` は `advice.ts:116` と同じ作法)。
*/
export async function loadDisplayLimitSettings(): Promise<DisplayLimitSetting[]> {
const keys = Object.keys(DISPLAY_LIMIT_HANDLERS) as DisplayLimitKey[];
return Promise.all(keys.map((key) => DISPLAY_LIMIT_HANDLERS[key].setting()));
}