/**
* パスパラメータ(`/clean-logs/{id}/...`、`/ds-devices/{serial_number}/...`)の検証。
*
* ★★`serial_number` は Prisma の `where` に入る★★(指示書AH 段階2 —
* `prismaSource.dsDeviceExists()` が `where: { serial_number }` で照会する)。
* ★値はクエリビルダ経由で束縛される★(生SQLはアプリコードに1件も無い。`CLAUDE.md` §3)が、
* ★入口で長さを締めておくのは変わらず有効★(`controls.serial_number` は varchar(100))。
* ★★当たり外れを決めるのはここではない★★ — DBの照合順序は大小文字を区別しない(ci)ため、
* 最終判定は `dsDeviceExists()` 側の JS の `===` が行う(D-4)。
* 保存先パスも `lib/settings/ds-device-notes.ts` で固定、マップは `Object.create(null)`。
* (`id` 側は `logs.find((l) => l.id === logId)` の等値比較と Prisma の `where`。
* 指示書AE 段階2-2 / D-123)
*
* ★`lib/api/` に置く理由★
* パスパラメータの形式とエラー形式(`CLAUDE.md` §9.3)は全APIルート共通の関心事で、
* `lib/clean-logs/` や `lib/ds-devices/` という個別ドメインの持ち物ではない。
* `lib/api/query-result.ts`(`fail()`)と同じ考え方。
*
* ★純粋関数にすること★(`CLAUDE.md` §9.4)— DBアクセスやログ出力を混ぜない。
*/
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import {
PATH_ERROR_ID_MAX_LENGTH,
PATH_ID_MAX,
PATH_REPORT_ID_MAX_LENGTH,
PATH_SERIAL_NUMBER_MAX_LENGTH,
} from "@/lib/config";
/**
* 数値ID用のパスパラメータを検証する。
*
* ★`Number.parseInt()` に判定を任せないこと★
* `parseInt` は**前方一致**なので、次が黙って通ってしまう:
* `"-5"` → `-5` / `"12abc"` → `12` / `"1.9"` → `1` / `"1e9"` → `1`。
* どれも「送った文字列とは違う値」で検索が走るので、404 になるべき要求が
* 別のレコードに当たりうる。
*
* ★`^\d+$` の全体一致で判定する★ — `lib/reports/query.ts` の `toIntStrict()`(`^-?\d+$`)が
* 既にある前例で、それに倣っている。ID は 1 以上なので符号を許さない分だけ狭い。
* なお JavaScript の `\d` は ASCII の `[0-9]` だけなので、全角数字(`"1"`)は通らない。
* `$` も(`m` フラグが無いので)末尾改行を許さない。
*
* ★トリムしない★ — `toIntStrict()` は `.trim()` してから判定するが、あれは利用者が
* 手で組み立てるクエリ文字列用。パスセグメントの前後に空白が付く正当な経路は無く、
* 緩める理由が無い。
*
* @returns 成功なら `{ ok: true, query: <正の整数> }`、失敗なら 422 の `QueryParseFailure`
*/
export function parsePathId(value: string): QueryParseResult<number> {
if (!/^\d+$/.test(value)) {
return invalidId();
}
const n = Number(value);
// `^\d+$` を通っていても、桁数次第で `Number()` の結果が元の値とずれる
// (安全整数を超えると丸められる)。丸められた値で検索させない。
if (!Number.isSafeInteger(n) || n > PATH_ID_MAX || n < 1) {
return invalidId();
}
return { ok: true, query: n };
}
/**
* D-S制御盤のシリアル番号のパスパラメータを検証する。
*
* ★長さ上限だけをかけ、文字種は絞らない★ — 理由は
* `lib/config/index.ts` の `PATH_SERIAL_NUMBER_MAX_LENGTH` のコメントに書いた
* (実データの形式が未確定。`CLAUDE.md` §7「仕様が不明な箇所を推測で実装しない」)。
* 空文字も弾く(Next.js のルーティング上そもそも来ないが、
* 「空で実在チェックを通す」経路を残さない)。
*/
export function parsePathSerialNumber(value: string): QueryParseResult<string> {
if (value.length === 0 || value.length > PATH_SERIAL_NUMBER_MAX_LENGTH) {
// ★入力値をエコーしない★(`ds-devices/[serial_number]/note/route.ts` の
// `DEVICE_NOTE_MAX_LENGTH` を details に入れる前例に倣い、上限値だけを返す)
return fail(422, "validation_failed", "serial_numberの形式が不正です", {
serial_number: [`${PATH_SERIAL_NUMBER_MAX_LENGTH}文字以内で指定してください`],
});
}
return { ok: true, query: value };
}
/**
* アラートの `error_id`(`robot_error_reports.error_id` = `varchar(50)`)のパスパラメータを検証する
* (`PATCH /prechecks/{id}/status`。指示書AT 段階1)。
*
* ★`parsePathSerialNumber()` と同じ作法★ — ★空文字と上限超だけを 422★ にし、文字種は絞らない
* (`error_id` の形式は TeamWorks 側が決める。`PATH_ERROR_ID_MAX_LENGTH` のコメント)。
* ★`Number()` を通さない★(`varchar` なので。D-105 項2)。
*/
export function parsePathErrorId(value: string): QueryParseResult<string> {
if (value.length === 0 || value.length > PATH_ERROR_ID_MAX_LENGTH) {
// ★入力値をエコーしない★(上限値だけを返す)
return fail(422, "validation_failed", "idの形式が不正です", {
id: [`${PATH_ERROR_ID_MAX_LENGTH}文字以内で指定してください`],
});
}
return { ok: true, query: value };
}
/**
* 予定なしの清掃結果 `report_id`(`clean_task_reports.report_id` = `varchar(20)`)のパスパラメータを検証する
* (`GET /clean-logs/unscheduled/{report_id}/advice`。指示書BI 段階1)。
*
* ★`parsePathErrorId()` と同じ作法★ — ★空文字と上限超だけを 422★ にし、文字種は絞らない。
* ★`Number()` / `BigInt()` を通さない★(19桁は安全な整数を超える)。
*/
export function parsePathReportId(value: string): QueryParseResult<string> {
if (value.length === 0 || value.length > PATH_REPORT_ID_MAX_LENGTH) {
return fail(422, "validation_failed", "report_idの形式が不正です", {
report_id: [`${PATH_REPORT_ID_MAX_LENGTH}文字以内で指定してください`],
});
}
return { ok: true, query: value };
}
/**
* ★エラー文言に入力値をエコーしないこと★
* 受け取った文字列をそのまま返すと、ログやブラウザの表示に
* 攻撃者が選んだ文字列が載る経路になる(`app/api/auth/login/route.ts:46-51` が
* ログイン失敗時に入力値を出さないのと同じ考え方)。
* 返すのは「何を期待しているか」だけにする。
*/
function invalidId(): QueryParseResult<number> {
return fail(422, "validation_failed", "idの形式が不正です", {
id: ["1以上の整数で指定してください"],
});
}