Newer
Older
client_dwatch / lib / settings / advice-validation.ts
/**
 * `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;
}