Newer
Older
client_dwatch / app / api / settings / advice / route.ts
/**
 * GET  /settings/advice          — アドバイス文言の一覧(既定値+編集内容)
 * PATCH /settings/advice         — 1ルール分の文言を保存
 *
 * システム管理 →「運用設定」から使います。
 * 編集できるのは title / text / actions のみ。
 * level / icon / condition(発火条件)は仕様なので画面からは変更できません。
 */
import { NextResponse } from "next/server";
import {
  DEFAULT_ADVICE_RULES,
  type AdviceRuleId,
  type EditableAdviceFields,
} from "@/lib/config/advice-rules";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { loadAdviceRules, resetAdviceRule, saveAdviceRule } from "@/lib/settings/advice";
import {
  isStringArray,
  validateAdviceFields,
  type AdviceFieldsToValidate,
} from "@/lib/settings/advice-validation";
import type { AdviceSetting, ApiError, ErrorCode } from "@/lib/types";

const ROUTE = "/api/settings/advice";

function errorBody(code: ErrorCode, message: string): ApiError {
  return { error: { code, message, details: null } };
}

function errorResponse(status: number, code: ErrorCode, message: string) {
  return NextResponse.json(errorBody(code, message), { status });
}

export async function GET(request: Request) {
  return withRequestId(request, async () => {
    const rules = await loadAdviceRules();
    const items: AdviceSetting[] = Object.values(rules).map((r) => ({
      rule: r.id,
      phase: r.phase,
      level: r.level,
      icon: r.icon,
      title: r.title,
      text: r.text,
      actions: r.actions,
      condition: r.condition,
      /** 既定値から変更されているか(画面に「編集済み」バッジを出す用) */
      customized:
        r.title !== DEFAULT_ADVICE_RULES[r.id].title ||
        r.text !== DEFAULT_ADVICE_RULES[r.id].text ||
        JSON.stringify(r.actions) !==
          JSON.stringify(DEFAULT_ADVICE_RULES[r.id].actions),
    }));
    return NextResponse.json({ items });
  });
}

export async function PATCH(request: Request) {
  return withRequestId(request, async () => {
    let body: {
      rule?: string;
      title?: string;
      text?: string;
      /** ★`string[]` と書かない★ — JSONで何が来るかは実行時まで分からない。
       *  型で嘘をつくと `filter` や `as` で握りつぶす実装に流れる */
      actions?: unknown;
      reset?: boolean;
    };

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

    const ruleId = body.rule as AdviceRuleId | undefined;
    if (!ruleId || !DEFAULT_ADVICE_RULES[ruleId]) {
      return errorResponse(422, "validation_failed", "ruleの値が不正です");
    }

    try {
      if (body.reset) {
        const reverted = await resetAdviceRule(ruleId);
        return NextResponse.json({ rule: reverted.id, reset: true });
      }

      const fields: AdviceFieldsToValidate = {};
      if (typeof body.title === "string") fields.title = body.title;
      if (typeof body.text === "string") fields.text = body.text;
      // ★要素の型は見ない★ — 文字列以外が混ざっていたら
      //   `validateAdviceFields()` が 422 にする(落として保存すると
      //   `actions: [1,2,3]` が「全消去」になり、黙って既存の編集が消える)
      if (Array.isArray(body.actions)) fields.actions = body.actions;

      if (Object.keys(fields).length === 0) {
        return errorResponse(
          422,
          "validation_failed",
          "title / text / actions のいずれかを指定してください"
        );
      }

      // ★長さ・件数の上限(指示書AE 段階2-3 / D-124)★
      //   保存先が ds-device-notes とまったく同じ「全件読み込み→書き戻し」の
      //   JSON ストアなので、DEVICE_NOTE_MAX_LENGTH と同じ理由で上限が要る。
      //   上限値は lib/config/index.ts(CLAUDE.md §7。直書きしない)
      const invalid = validateAdviceFields(fields);
      if (invalid) {
        return NextResponse.json(invalid.error, { status: invalid.status });
      }

      // ★ここに来た時点で actions は全要素が文字列★(そうでなければ上で422)。
      //   その事実を型の世界へ渡すための型ガード。`as` で潰すと、検証を外したときに
      //   型エラーで気付けなくなる。
      //   ★キーは「指定されたものだけ」入れる★ — `title: undefined` を混ぜると
      //   保存側の `{ ...既定値, ...fields }` で既定値を undefined で上書きしてしまう。
      const toSave: Partial<EditableAdviceFields> = {};
      if (fields.title !== undefined) toSave.title = fields.title;
      if (fields.text !== undefined) toSave.text = fields.text;
      const actions = fields.actions;
      if (actions !== undefined && isStringArray(actions)) {
        // 複製して保存側に呼び出し元の配列参照を渡さない
        toSave.actions = [...actions];
      }

      const saved = await saveAdviceRule(ruleId, toSave);
      return NextResponse.json({
        rule: saved.id,
        title: saved.title,
        text: saved.text,
        actions: saved.actions,
      });
    } catch (e) {
      const errorPayload = errorBody("internal_error", "設定の保存に失敗しました");
      logApiError({
        route: ROUTE,
        method: "PATCH",
        body: errorPayload,
        status: 500,
        err: e,
      });
      return NextResponse.json(errorPayload, { status: 500 });
    }
  });
}