Newer
Older
client_dwatch / app / api / schedules / route.ts
/**
 * GET /schedules — スケジュール一覧(F-09。読み取り専用)
 *
 * 形は `app/api/ds-devices/route.ts` と同じ。
 * 仕様の正は `d-watch-api.yaml` の /schedules。
 *
 * ★このAPIに summary は無い★(`items` と `meta` のみ。YAML の /schedules 参照)。
 *   F-09 は「登録済みスケジュールの台帳」を見る画面で、
 *   異常件数のような集計を出す画面ではないため。
 *
 * ★登録・更新は D-Security 側の画面で行うため、D-Watch は GET のみ★(YAML の description)。
 *   POST/PUT/DELETE をここに足さないこと(CLAUDE.md §3 のとおり、メインDBへの書き込みは
 *   `robot_error_reports` / `control_error_histories` の4カラムだけが許可されている)。
 */
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 {
  filterSchedules,
  paginateSchedules,
  parseScheduleQuery,
  sortSchedules,
} from "@/lib/schedules/query";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, ListResponse, Schedule } from "@/lib/types";

const ROUTE = "/api/schedules";

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 = parseScheduleQuery(searchParams);
    if (!parsed.ok) {
      return errorResponse(parsed.error, parsed.status);
    }

    let rows: Schedule[];
    // ★分類(`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.schedules();
    } 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);
    }

    // ここから下は純粋な絞り込み・並べ替え。すべて try の外
    // (`app/api/ds-alerts/route.ts` と同じ方針)。
    //
    // ★`active` では絞り込まない★ 有効・無効の両方を返し、画面が「有効」列で見せ分ける。
    //   詳細は `filterSchedules` の JSDoc。
    // ★分類は `Schedule.facility_id` で突き合わせる★(指示書AK 段階4)。
    //   ★なぜ SQL ではなくここで絞るのかは `lib/facilities/filter-by-ids.ts` の冒頭★。
    const filtered = filterSchedules(
      filterRowsByFacilityIds(rows, facilityIds),
      parsed.query
    );
    const sorted = sortSchedules(filtered);

    const { page, perPage } = parsed.query;
    // meta.total は「絞り込み後・ページング前」の総件数(ページャの算出元)。
    // page が範囲外なら items は空になるが、total は全件数のまま返す。
    const total = sorted.length;
    const items = paginateSchedules(sorted, page, perPage);

    const body: ListResponse<Schedule> = {
      items,
      meta: { total, page, per_page: perPage },
    };
    return NextResponse.json(body);
  });
}