/**
* 分類マスタのID(`group_id` / `customer_id`)のクエリパラメータ検証。
*
* ★`lib/api/` に置く理由★
* この2つは `/groups` `/customers` だけでなく、★一覧系11エンドポイントの
* 絞り込みパラメータ★でもある(`d-watch-api.yaml` の `GroupId` / `CustomerId`)。
* 検証規則が `lib/prechecks/` や `lib/reports/` に写し取られて散ると、
* ★どれか1つだけ緩い★状態が生まれても誰も気づけない。
* `lib/api/path-params.ts`(パスパラメータ)と同じ考え方。
*
* ★純粋関数にすること★(`CLAUDE.md` §9.4)— DBアクセスやログ出力を混ぜない。
*/
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { PATH_ID_MAX } from "@/lib/config";
/** 検証するパラメータ名。エラーの `details` のキーにそのまま使う */
export type MasterIdParamName = "group_id" | "customer_id";
/**
* `group_id` / `customer_id` のクエリパラメータを検証する。
*
* | 入力 | 結果 |
* |---|---|
* | 指定なし(`null`)・空文字 | `null`(絞り込まない) |
* | `"1"` | `1` |
* | ★`"0"`★ | ★422★(0 は「グループなし」のセンチネルであって、有効なIDではない。D-011) |
* | ★`"1abc"`★ | ★422★(★`Number.parseInt()` は前方一致で `1` にしてしまう★。D-123) |
* | `"-1"` / `"1.5"` / 安全整数超 | 422 |
*
* ★`Number.parseInt()` に判定を任せないこと★ — `parsePathId()`(`lib/api/path-params.ts`)の
* JSDoc に書いてあるとおり、`"12abc"` → `12` / `"1.9"` → `1` / `"1e9"` → `1` が黙って通る。
* ★送った文字列とは違うIDで絞り込みが走る★ので、★`^\d+$` の全体一致★で判定する。
*
* ★空文字を 422 にしない理由★ — `?group_id=` は「未指定」であって不正値ではない
* (画面のセレクトを「全ホールディングス」に戻したときにこの形になりうる)。
* 前後の空白も落とす(`lib/reports/query.ts` の `toIntStrict()` が `.trim()` するのと同じ。
* クエリ文字列は手で組み立てられる経路があるため)。
*
* ★エラー文言に入力値をエコーしない★(`parsePathId()` と同じ理由。
* 受け取った文字列をログやブラウザの表示に載せる経路を作らない)。
*/
export function parseMasterIdParam(
value: string | null,
name: MasterIdParamName
): QueryParseResult<number | null> {
const raw = value?.trim() ?? "";
if (raw === "") {
return { ok: true, query: null };
}
if (!/^\d+$/.test(raw)) {
return invalid(name);
}
const n = Number(raw);
// `^\d+$` を通っていても、桁数次第で `Number()` の結果が元の値とずれる
// (安全整数を超えると丸められる)。丸められた値で絞り込ませない。
if (!Number.isSafeInteger(n) || n > PATH_ID_MAX || n < 1) {
return invalid(name);
}
return { ok: true, query: n };
}
function invalid(name: MasterIdParamName): QueryParseResult<number | null> {
return fail(422, "validation_failed", `${name}の形式が不正です`, {
[name]: ["1以上の整数で指定してください"],
});
}
/**
* 一覧系APIの絞り込み用に `group_id` と `customer_id` をまとめて検証する
* (指示書AK 段階2)。★7本のクエリパーサがこれ1本を呼ぶ★。
*
* ★★同時指定は AND(両方を満たす施設だけ)★★ — `d-watch-api.yaml` の
* `GroupId` / `CustomerId` の description に書いた契約と同じ。
* ★どちらか片方を捨てないこと★(指示書AK §6-2 の積集合と同じ規則)。
* ★★この関数は「読んで検証する」だけ★★で、施設への解決(段階3)や
* `where` への反映(段階4)はしない。ここで AND / OR を決めてしまわないこと。
*
* ★フィールド名は camelCase★ — 同じ指示書の段階1で作った
* `CustomerQuery.groupId`(`lib/masters/query.ts`)と、既存の
* `CleanLogQuery.dateFrom` 等に合わせている。HTTP のクエリ名(snake_case)は
* `searchParams.get()` の引数にだけ現れる(`CLAUDE.md` §9.2)。
*
* ★検証の順序は group_id → customer_id★(両方が不正なら group_id の 422 を返す)。
* ★純粋関数にすること★(`CLAUDE.md` §9.4)。
*/
export interface MasterIdFilters {
/** ホールディングス(`groups.id`)。★`null` = 絞らない★ */
groupId: number | null;
/** 顧客(`customers.id`)。★`null` = 絞らない★ */
customerId: number | null;
}
export function parseMasterIdFilters(
searchParams: URLSearchParams
): QueryParseResult<MasterIdFilters> {
const groupId = parseMasterIdParam(searchParams.get("group_id"), "group_id");
if (!groupId.ok) return groupId;
const customerId = parseMasterIdParam(
searchParams.get("customer_id"),
"customer_id"
);
if (!customerId.ok) return customerId;
return {
ok: true,
query: { groupId: groupId.query, customerId: customerId.query },
};
}