Newer
Older
client_dwatch / app / api / settings / display-limits / route.ts
/**
 * GET   /settings/display-limits — 表示上限の一覧(既定値+編集内容)
 * PATCH /settings/display-limits — 表示上限を1件更新する / 既定値に戻す
 *
 * システム管理 →「運用設定」から使います(指示書AF 段階4 / ユーザー指示 2026-09-13)。
 *
 * ★保存先はメインDBではありません★
 *   `lib/settings/display-limits.ts`(DFA独自のJSONストア。
 *   `data/settings/display-limits.json`)に書きます。メインDBはTeamWorks管理で、
 *   DFAが更新できるのは `robot_error_reports` / `control_error_histories` の
 *   4カラムだけです(`CLAUDE.md` §3 / §7)。
 *
 * ★検証はここに書きません★
 *   `validateConsecutiveDaysMax()`(`lib/settings/display-limits.ts`)が唯一の正で、
 *   PATCH と保存済み値の読み込みの両方が同じ関数を通ります。
 *   APIルートの単体テストが1件も無いリポジトリなので、検証は純粋関数に寄せてあります。
 *
 * ★どのキーがあるかも、ここには書きません★
 *   `lib/settings/display-limits.ts` の `DISPLAY_LIMIT_HANDLERS`
 *   (`Record<DisplayLimitKey, …>`)が唯一の正です。このルートは
 *   `isDisplayLimitKey()` / `validateDisplayLimit()` / `saveDisplayLimit()` /
 *   `resetDisplayLimit()` に `key` を渡すだけで、キー名で分岐しません。
 *   ★ここにキー一覧を持つと、2つ目のキーを足したときに
 *   「別キーへの PATCH が `consecutive_days_max` を書き換える」★ 形になります
 *   (例外もテスト失敗も出ません)。
 *
 * ★`export const dynamic = "force-dynamic"` は付けていません(実測して不要と確認した)★
 *   このGETはクエリパラメータを持たないので静的化を疑うのが正しい(静的化されると
 *   保存しても画面に反映されない)。Next.js 16.3.3 の
 *   `node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md:51`:
 *   ★「Route Handlers are not cached by default」★ — キャッシュしたいときに
 *   `export const dynamic = 'force-static'` を**足す**側の設計になっている。
 *   Cache Components(有効時は GET が prerender されうる)は `next.config.ts` で
 *   有効化していない。★`npm run build` の出力で `ƒ /api/settings/display-limits`
 *   (Dynamic)であることを確認済み★(2026-09-13。`/api/settings/advice` も同じ)。
 *   ★Next を上げたときは build 出力で `○ (Static)` に変わっていないか見ること★。
 *
 * ★`min` / `max` をここで組み立てないこと★
 *   範囲は `lib/config` の `CONSECUTIVE_DAYS_DISPLAY` が正で、GET のレスポンスに
 *   そのまま載ります。画面の入力欄はその値を使います(`CLAUDE.md` §7 ハードコード禁止)。
 */
import { NextResponse } from "next/server";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import {
  isDisplayLimitKey,
  loadDisplayLimitSettings,
  resetDisplayLimit,
  saveDisplayLimit,
  validateDisplayLimit,
} from "@/lib/settings/display-limits";
import type {
  ApiError,
  DisplayLimitKey,
  DisplayLimitSetting,
  ErrorCode,
} from "@/lib/types";

const ROUTE = "/api/settings/display-limits";

function errorBody(
  code: ErrorCode,
  message: string,
  details: Record<string, string[]> | null = null
): ApiError {
  return { error: { code, message, details } };
}

function errorResponse(
  status: number,
  code: ErrorCode,
  message: string,
  details: Record<string, string[]> | null = null
) {
  return NextResponse.json(errorBody(code, message, details), { status });
}

/**
 * 更新後の1件を返す。
 *
 * ★保存した値をそのままエコーバックしないこと★ — 画面は「送った値」ではなく
 * 「実際に有効になった値」で表示を更新する必要があります(`customized` も
 * ストアを読まないと決まりません。`ds-devices/[serial_number]/note` と同じ規律)。
 */
async function settingFor(key: DisplayLimitKey): Promise<DisplayLimitSetting> {
  const items = await loadDisplayLimitSettings();
  const item = items.find((i) => i.key === key);
  if (!item) {
    // `DISPLAY_LIMIT_HANDLERS` から一覧を作っているので、通常は到達しません
    // (`isDisplayLimitKey()` が通った key = 表にあるキー)。
    // 黙って200を返すと、画面には何も反映されないのに成功したように見えるので残しています
    throw new Error(`表示上限の定義が見つかりません: ${key}`);
  }
  return item;
}

export async function GET(request: Request) {
  return withRequestId(request, async () => {
    const items = await loadDisplayLimitSettings();
    return NextResponse.json({ items });
  });
}

export async function PATCH(request: Request) {
  return withRequestId(request, async () => {
    let body: {
      /** ★`string` と書かない★ — JSONで何が来るかは実行時まで分からない */
      key?: unknown;
      value?: unknown;
      reset?: unknown;
    };

    try {
      body = await request.json();
    } catch {
      return errorResponse(422, "validation_failed", "JSONの形式が不正です");
    }

    // ★キーを知っているのは `lib/settings/display-limits.ts` の対応表だけ★ —
    //   ここに独自のキー一覧を置くと、表に足したキーがルートでは弾かれる(またはその逆)。
    //   未知のキーはここで 422(`d-watch-api.yaml` の `DisplayLimitSetting.key` の enum 外)
    const key = body.key;
    if (!isDisplayLimitKey(key)) {
      return errorResponse(422, "validation_failed", "keyの値が不正です");
    }

    try {
      // ★検証も保存もリセットも `key` を渡すこと★ — 特定のキー専用の関数
      //   (`saveConsecutiveDaysMax()` 等)をここから直接呼ばない。
      //   呼ぶと、2つ目のキーへの PATCH が `consecutive_days_max` を書き換えます
      //   (例外もテスト失敗も出ない。指示書AF §H-1 の「他の閾値へ広げる」で必ず踏む)
      // ★`reset` を `value` より先に見る★ — 既定値に戻す操作では value を送らないため
      if (body.reset === true) {
        await resetDisplayLimit(key);
        return NextResponse.json(await settingFor(key));
      }

      const outcome = validateDisplayLimit(key, body.value);
      if (!outcome.ok) {
        // details は field 別のメッセージ。★入力値をエコーしない★
        //   (`validateConsecutiveDaysMax()` の reason は範囲の説明だけで、
        //    送られた値を含まない)
        return errorResponse(422, "validation_failed", "valueの値が不正です", {
          value: [outcome.reason],
        });
      }

      await saveDisplayLimit(key, outcome.value);
      return NextResponse.json(await settingFor(key));
    } catch (e) {
      // ★`err.message` を本文に載せないこと★(保存先の絶対パス等が画面へ出る)
      const errorPayload = errorBody("internal_error", "設定の保存に失敗しました");
      logApiError({
        route: ROUTE,
        method: "PATCH",
        body: errorPayload,
        status: 500,
        err: e,
      });
      return NextResponse.json(errorPayload, { status: 500 });
    }
  });
}