Newer
Older
client_dwatch / app / api / ds-devices / route.ts
/**
 * 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);
  });
}