/**
* GET /prechecks で使うクエリ処理。
* 仕様の正は `d-watch-api.yaml` の /prechecks。ソート順は D-052、status=open の意味は D-054。
*/
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { parseDate } from "@/lib/clean-logs/query";
import { PAGINATION } from "@/lib/config";
import type { PrecheckItem, PrecheckStatus, PrecheckStatusFilter } from "@/lib/types";
const STATUS_FILTERS: readonly PrecheckStatusFilter[] = [
"open",
"unresolved",
"in_progress",
"resolved",
"all",
];
export interface PrecheckQuery {
/**
* YYYY-MM-DD。scheduled_at の先頭10文字と文字列比較する(TZに依存しない)。
* ★★指定なしは必ず `null`(★空文字を入れないこと★。`parsePrecheckQuery()` の注記)★★
*/
dateFrom: string | null;
dateTo: string | null;
status: PrecheckStatusFilter;
/**
* ホールディングス(`groups.id`)で絞り込み。★`null` = 絞らない★(指示書AK 段階2)。
* ★`customerId` と同時に指定されたら AND★(`d-watch-api.yaml` の `GroupId`)。
*/
groupId: number | null;
/** 顧客(`customers.id`)で絞り込み。★`null` = 絞らない★ */
customerId: number | null;
page: number;
perPage: number;
}
function toInt(value: string | null, fallback: number): number {
if (!value) return fallback;
const n = Number.parseInt(value, 10);
return Number.isNaN(n) ? fallback : n;
}
/**
* クエリパラメータをパースする。
* 不正な値があれば `{ ok: false }` を返す(`error` / `status` をそのまま422応答に使う。
* どのパラメータが不正かは `error.error.details` に入る)。
* ★group_id / customer_id を読む(指示書AK 段階2。D-053 の「受け取っても無視する」は解除)★
* 検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる。
* ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
*
* page / per_page は不正値でも失敗させず、既定値・上限へ丸める(/clean-logs と同じ挙動)。
*/
export function parsePrecheckQuery(
searchParams: URLSearchParams
): QueryParseResult<PrecheckQuery> {
// ★★空文字は「指定なし」として `null` に正規化する★★(`?date_to=` のように
// 値だけ空で送られてくる経路がある)。
// ★`""` のまま下へ渡すと `db.prechecks()` の実装で意味が割れます★:
// - Prisma 版 … `dbDateRange("")` → `parseDate("")` が `null` → ★条件が付かず全件★
// - fixtures 版 … `date_to !== null` が真・`"2026-09-10" > ""` も真 → ★全行除外で0件★
// ★★`USE_FIXTURES` の true/false で件数が変わる★★(指示書AC 禁止事項6)。
// ★例外もテスト失敗も出ないので、入口で潰しておくこと★
// (`filterPrechecksByDate()` の truthy 判定とも意味が揃う)。
const dateFromRaw = searchParams.get("date_from") || null;
const dateToRaw = searchParams.get("date_to") || null;
if (dateFromRaw && !parseDate(dateFromRaw)) {
return fail(422, "validation_failed", "date_fromの形式が不正です", {
date_from: ["YYYY-MM-DD形式で指定してください"],
});
}
if (dateToRaw && !parseDate(dateToRaw)) {
return fail(422, "validation_failed", "date_toの形式が不正です", {
date_to: ["YYYY-MM-DD形式で指定してください"],
});
}
const statusRaw = searchParams.get("status") ?? "open";
if (!STATUS_FILTERS.includes(statusRaw as PrecheckStatusFilter)) {
return fail(422, "validation_failed", "statusの値が不正です", {
status: [`${STATUS_FILTERS.join(" / ")} のいずれかを指定してください`],
});
}
const masters = parseMasterIdFilters(searchParams);
if (!masters.ok) return masters;
const page = Math.max(1, toInt(searchParams.get("page"), 1));
const perPage = Math.min(
PAGINATION.maxPerPage,
Math.max(1, toInt(searchParams.get("per_page"), PAGINATION.defaultPerPage))
);
return {
ok: true,
query: {
dateFrom: dateFromRaw,
dateTo: dateToRaw,
status: statusRaw as PrecheckStatusFilter,
groupId: masters.query.groupId,
customerId: masters.query.customerId,
page,
perPage,
},
};
}
/**
* 日付範囲のみで絞り込む。status では絞らない。
*
* ★summary はこの関数の結果に対して計算する(status フィルタの前)★
* D-054: summary が対応状況フィルタに追従すると、既定表示(status=open)で
* 解消済みカードが常に0になり、サマリーの役目を果たさなくなる
*
* 日付は scheduled_at の先頭10文字で比較する(ISO8601 +09:00 なので
* 先頭10文字が JST の日付そのものになり、実行環境のTZに依存しない。
* app/api/dashboard/route.ts と同じやり方)
*/
export function filterPrechecksByDate(
items: PrecheckItem[],
query: PrecheckQuery
): PrecheckItem[] {
return items.filter((item) => {
const day = item.scheduled_at.slice(0, 10);
if (query.dateFrom && day < query.dateFrom) return false;
if (query.dateTo && day > query.dateTo) return false;
return true;
});
}
/** 対応状況のみで絞り込む。一覧(items)はこの結果を使う。summary には使わない(D-054) */
export function filterPrechecksByStatus(
items: PrecheckItem[],
status: PrecheckStatusFilter
): PrecheckItem[] {
return items.filter((item) => {
switch (status) {
case "unresolved":
return item.resolution_status === "unresolved";
case "in_progress":
return item.resolution_status === "in_progress";
case "resolved":
return item.resolution_status === "resolved";
case "open":
return item.resolution_status !== "resolved";
case "all":
return true;
}
});
}
/**
* detected_at の降順。同値のときは id の昇順で安定させる(D-052)
*
* ★`id` は `string`(D-070 1-2 / D-105)なので数値減算ではなく文字列比較で並べる★。
* D-052 の契約(detected_at 降順 / 同値は id 昇順)は変えていない。
* ★辞書順なので `"10" < "9"` になる★が、`id` は `error_id`(`varchar(50)`)由来で
* 数値である保証がなく、数値としての昇順は定義できない。安定した順序であることが要件。
*/
export function sortPrechecks(items: PrecheckItem[]): PrecheckItem[] {
return [...items].sort((a, b) => {
if (a.detected_at !== b.detected_at) {
return a.detected_at < b.detected_at ? 1 : -1;
}
if (a.id === b.id) return 0;
return a.id < b.id ? -1 : 1;
});
}
export interface PrecheckSummary {
/** 解消していない件数(未解消+対応中)。resolved との合計が total になる(D-054) */
unresolved: number;
resolved: number;
total: number;
}
export function summarizePrechecks(items: PrecheckItem[]): PrecheckSummary {
const resolved = items.filter(
(item) => item.resolution_status === "resolved"
).length;
const total = items.length;
return { unresolved: total - resolved, resolved, total };
}
/**
* 1行の対応状況が `before` → `after` に変わったときのサマリを、差分だけ反映して返す(指示書AT §3-8)。
*
* 画面(`/alerts`)は更新に成功しても一覧を取り直さない(その1行だけ差し替える)ので、
* サマリのカードもここで合わせる。★`unresolved` は「未解消+対応中」★(D-054)なので、
* 未解消↔対応中では動かない。解消に入る・出るときだけ `unresolved` と `resolved` が1ずつ動く。
* ★`total` は不変★。元のオブジェクトは書き換えない。
*/
export function applyResolutionChange(
summary: PrecheckSummary,
before: PrecheckStatus,
after: PrecheckStatus
): PrecheckSummary {
const wasResolved = before === "resolved";
const isResolved = after === "resolved";
if (wasResolved === isResolved) return { ...summary };
const delta = isResolved ? 1 : -1;
return {
unresolved: summary.unresolved - delta,
resolved: summary.resolved + delta,
total: summary.total,
};
}