/**
* GET /ds-devices — D-Secログ(D-S制御盤稼働状況一覧+サマリ)
*
* 形は `app/api/prechecks/route.ts` と同じ。
* 仕様の正は `d-watch-api.yaml` の /ds-devices、ソート順は docs/decisions.md D-055。
*/
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { filterRowsByFacilityIds } from "@/lib/facilities/filter-by-ids";
import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
import {
filterDsDevicesByQ,
filterDsDevicesByStatus,
parseDsDeviceQuery,
sortDsDevices,
summarizeDsDevices,
} from "@/lib/ds-devices/query";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { readDeviceNotes } from "@/lib/settings/ds-device-notes";
import type { ApiError, DsDevice, SummaryListResponse } from "@/lib/types";
import type { DsDeviceSummary } from "@/lib/ds-devices/query";
import type { DeviceNotes } from "@/lib/settings/ds-device-notes";
const ROUTE = "/api/ds-devices";
function errorResponse(error: ApiError, status: number) {
return NextResponse.json(error, { status });
}
export async function GET(request: Request) {
return withRequestId(request, async () => {
const { searchParams } = new URL(request.url);
const parsed = parseDsDeviceQuery(searchParams);
if (!parsed.ok) {
return errorResponse(parsed.error, parsed.status);
}
let rows: DsDevice[];
let notes: DeviceNotes;
// ★分類(`group_id` / `customer_id`)→ 施設IDの配列★(指示書AK 段階4)。
// ★`null` = 絞らない / `[]` = 0件★(★罠1★。`?.length` で分岐しないこと)。
let facilityIds: number[] | null;
try {
// ★解決(施設マスタの読み取り)も同じ try に入れる★ — 失敗したら 500。
// ★黙って絞らずに返さないこと★(他社のデバイスが混ざったまま画面に出る)。
facilityIds = await resolveFacilityIdsByMasters(parsed.query);
rows = await db.dsDevices();
// ★備考の読み込みも同じ try に含める=失敗したら一覧全体を500にする★
// 「備考はおまけなので読めなくても200で返す」も一案だが、ここでは採らない:
// 1. `readDeviceNotes()` が例外を投げるのは**本物のサーバー障害のときだけ**。
// 未保存(ENOENT)は空で返り、JSON破損は `.bak` へ退避したうえで空が返る
// (`lib/settings/json-store.ts`)。つまりここまで例外が上がってくるのは
// 権限エラー・I/O障害・退避失敗といった、放置してはいけない状態に限られる。
// 2. 黙って備考なしで返すと、運用担当には「備考が消えた」としか見えず、
// 保存済みの文言を空欄と誤認して上書き(=実質削除)しかねない。
// 3. ログイベントは allowlist(`lib/logger/events.ts`)で、
// 「縮退して返した」を表すイベントは存在しない。`api.error` は
// 「catch節で処理してこのステータスを返した」という意味なので、
// 200を返しながらそれを出すとログとレスポンスがズレる。
// よって他のtry節と同じ形(失敗=500 + `api.error`)に揃え、一貫性を保つ。
notes = await readDeviceNotes();
} catch (e) {
const body: ApiError = {
error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
};
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return errorResponse(body, 500);
}
// ★備考はDFA独自ストアの値を優先する★
// 独自ストア(`data/settings/ds-device-notes.json`)の値は
// **運用担当がこの画面から上書きした最新の備考**であり、fixtures/DB 側の `note` は
// 初期値・フォールバックにすぎない。逆にすると、保存したそばから
// 古い値に戻って見えることになる。
// (メインDBは読み取り専用で備考は書けないため、保存先は独自ストアしかない。
// CLAUDE.md §3 / §7)
// ★フィルタ・ソート・サマリより前にマージする★。今の summary / q は
// `note` を見ていないが、後から「備考でも検索する」等が入ったときに
// マージ前の古い値で絞り込む事故を、順序で構造的に防いでおく。
rows = rows.map((device) => ({
...device,
note: notes[device.serial_number] ?? device.note,
}));
// ★★分類の絞り込みは summary より**前**にかける★★(指示書AK 段階4)—
// q / status は「見え方」の絞り込みなので summary に効かせない(D-054)が、
// ★分類は「母集団」そのものを選ぶ★ので ★summary にも効かせる★
// (「この屋号の異常◯件」が見たい。指示書AK §7-2 の数値カードと同じ理由)。
// ★★一覧と summary で母集団がずれないよう、必ず同じ配列から作ること★★。
// ★なぜ SQL ではなくここで絞るのかは `lib/facilities/filter-by-ids.ts` の冒頭★。
rows = filterRowsByFacilityIds(rows, facilityIds);
// ★summaryはq/statusフィルタの前(全件)に対して計算する(D-054と同じ理由)★
// フィルタ後に計算すると、既定の絞り込み状態によってサマリーが変わってしまう
const summary: DsDeviceSummary = summarizeDsDevices(rows);
const filtered = filterDsDevicesByStatus(
filterDsDevicesByQ(rows, parsed.query.q),
parsed.query.status
);
const sorted = sortDsDevices(filtered);
const { page, perPage } = parsed.query;
// ★meta.total と summary.total は意味が違う★
// meta.total = 一覧(q・statusフィルタ後)の総件数。ページング計算用
// summary.total = 全デバイス数(q・statusの影響を受けない)
// 絞り込みが無いとき(q="" かつ status=all)だけ一致する
const total = sorted.length;
const start = (page - 1) * perPage;
const items = sorted.slice(start, start + perPage);
const body: SummaryListResponse<DsDevice, DsDeviceSummary> = {
summary,
items,
meta: { total, page, per_page: perPage },
};
return NextResponse.json(body);
});
}