/**
* `PATCH /settings/advice` の入力検証(純粋関数)。
*
* ★なぜ上限が要るか★
* 保存先(`lib/settings/advice.ts`)は ★全件読み込み→書き戻し★ の JSON ストアで、
* `lib/settings/ds-device-notes.ts`(`DEVICE_NOTE_MAX_LENGTH` = 200 で上限をかけている)と
* ★まったく同じ保存方式★。それなのに `settings/advice` だけ型チェックしか無く、
* ★すぐ隣の実装と非対称だった★(指示書AE 段階2-3 / D-124)。
* 上限値の根拠は `lib/config/index.ts` の `ADVICE_TITLE_MAX_LENGTH` のコメントにある。
*
* ★ルート(`app/api/settings/advice/route.ts`)ではなくここに置く理由★
* 境界値をテストしたいため。ルートは `fs` と `withRequestId` を引き込むので、
* 「101文字なら422」を確かめるのに一時ディレクトリもロガーのモックも要らない形にする
* (`CLAUDE.md` §9.4「判定関数は純粋関数にする」と同じ考え方)。
*/
import { fail, type QueryParseFailure } from "@/lib/api/query-result";
import {
ADVICE_ACTION_MAX_LENGTH,
ADVICE_ACTIONS_MAX_ITEMS,
ADVICE_TEXT_MAX_LENGTH,
ADVICE_TITLE_MAX_LENGTH,
} from "@/lib/config";
/**
* 保存対象のフィールド。
*
* `title` / `text` はルート側の `typeof` で絞り込み済みのものを受け取る。
* ★`actions` だけは「配列であること」までしか絞り込まずに受け取る★ —
* 要素の型チェックはここ(検証の場所)でやり、契約違反は 422 で返すため。
* 理由は `validateAdviceFields()` のコメントを参照。
*/
export interface AdviceFieldsToValidate {
title?: string;
text?: string;
actions?: readonly unknown[];
}
/**
* 配列の要素がすべて文字列か。
*
* ★検証を通したあとにルート側が `string[]` として保存するための型ガード★でもある
* (`as` で握りつぶすと、検証を外したときに型エラーで気付けなくなる)。
*/
export function isStringArray(value: readonly unknown[]): value is readonly string[] {
return value.every((a) => typeof a === "string");
}
/**
* 上限を超えていれば 422 の失敗を返す。問題なければ `null`。
*
* ★`QueryParseResult<T>` の union ではなく `| null` なのは、ここに「パースして得た値」が
* 無いから★(`lib/api/query-result.ts` の冒頭コメントが `null` を戒めているのは、
* どのパラメータが不正かを呼び出し側に渡せなくなるため。ここは `fail()` で
* `details` を持った `QueryParseFailure` をそのまま返すので、その問題は起きない)。
*
* ★`details` には上限値だけを入れ、入力値をエコーしない★
* (`app/api/ds-devices/[serial_number]/note/route.ts:74` の前例に倣う)。
*
* ★文字数はトリム前の入力そのもので数える★
* (`ds-devices/.../note/route.ts:66-68` と同じ理由。画面の入力欄が数えるのも
* トリム前なので、ここでトリム後を見ると「画面上は上限内なのに保存が弾かれる」ズレが出る)。
*/
export function validateAdviceFields(
fields: AdviceFieldsToValidate
): QueryParseFailure | null {
if (fields.title !== undefined && fields.title.length > ADVICE_TITLE_MAX_LENGTH) {
return fail(422, "validation_failed", "titleが長すぎます", {
title: [`${ADVICE_TITLE_MAX_LENGTH}文字以内で入力してください`],
});
}
if (fields.text !== undefined && fields.text.length > ADVICE_TEXT_MAX_LENGTH) {
return fail(422, "validation_failed", "textが長すぎます", {
text: [`${ADVICE_TEXT_MAX_LENGTH}文字以内で入力してください`],
});
}
const actions = fields.actions;
if (actions !== undefined) {
// ★文字列以外の要素は「落とす」のではなく 422 で弾く★
// `d-watch-api.yaml` の契約が `actions: items: { type: string }` なので、
// 数値などが混ざった配列は★契約違反★。以前はルート側で
// `.filter((a) => typeof a === "string")` して落としていたが、
// `actions: [1, 2, 3]` が★空配列として保存され=既存アクションが黙って全消去★
// になり、しかもエラーも出なかった。★落とすと「消す編集」と区別できない★
// (空配列そのものは「全部消す」という正当な編集なので、下の件数チェックは通す)。
if (!isStringArray(actions)) {
return fail(422, "validation_failed", "actionsの要素が不正です", {
// ★入力値をエコーしない★ — 期待する形式だけを返す
actions: ["文字列の配列で指定してください"],
});
}
// ★数えるのは「実際に保存される配列」★
// 上限の目的が「ストアのファイルを肥大させない」ことなので、
// 保存される要素をそのまま数える。
if (actions.length > ADVICE_ACTIONS_MAX_ITEMS) {
return fail(422, "validation_failed", "actionsの件数が多すぎます", {
actions: [`${ADVICE_ACTIONS_MAX_ITEMS}件以内で指定してください`],
});
}
if (actions.some((a) => a.length > ADVICE_ACTION_MAX_LENGTH)) {
// ★何件目が長すぎるかも返さない★ — 件数から入力の中身が推測できる形にしない。
// 画面は1件ずつの入力欄なので、上限だけ分かれば直せる
return fail(422, "validation_failed", "actionsの要素が長すぎます", {
actions: [`1件あたり${ADVICE_ACTION_MAX_LENGTH}文字以内で入力してください`],
});
}
}
return null;
}