/**
* 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 });
}
});
}