Newer
Older
client_dwatch / app / api / clean-logs / route.ts
/**
 * GET /clean-logs — 清掃詳細ログ一覧
 *
 * ★これが「動く見本」です。他の一覧APIはこの形を真似して作ってください★
 *
 * 仕様の正は `d-watch-api.yaml` の /clean-logs です。
 * APIを変えたくなったら、先にYAMLを直してからコードに反映してください(CLAUDE.md 作業ルール1)。
 *
 * このルートがやっていること
 *   1. クエリパラメータのパースとバリデーション(`MAX_DATE_RANGE_DAYS` 超は422。
 *      ★上限は2年=731日★。指示書AI 段階7 で 31日 から広げた)
 *   2. ★施設名 → 施設ID の解決★(指示書AC 段階2 D-6。★照合はアプリ側、絞り込みはSQL★)
 *   3. データソース(fixtures または Prisma)からの取得(★日付・施設はSQLに降りている★)
 *   4. フィルタ(日付範囲 / 施設名 / 機種 / シリアル / 判定)
 *   5. ページング
 *   6. { items, meta } 形式で返却
 *
 * ★★3 で絞ったのに 4 の `filterCleanLogs()` を残しているのは意図です★★(指示書AC C-5)—
 *   ★絞り込み規則の「正」を純粋関数1箇所に保つため★(SQL 側はその写し)。
 *   ★fixtures 版が引数の扱いを間違えても画面の見え方が変わらない保険にもなる★。
 *   ★★ただし「二重に絞っているから安全」ではありません★★ —
 *   ★SQL が**絞りすぎた**場合は `filterCleanLogs()` では取り戻せません★。
 */
import { NextResponse } from "next/server";
import {
  capCleanLogs,
  filterCleanLogs,
  parseCleanLogQuery,
  toDbQueryDate,
} from "@/lib/clean-logs/query";
import { CONSECUTIVE_DAYS_DISPLAY } from "@/lib/config";
import { db } from "@/lib/db";
import { intersectFacilityIds } from "@/lib/facilities/intersect-ids";
import { resolveFacilityIdsByMasters } from "@/lib/facilities/resolve-by-master";
import { resolveFacilityIdsByName } from "@/lib/facilities/resolve-by-name";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type { ApiError, CleanLogItem, ListResponse } from "@/lib/types";

const ROUTE = "/api/clean-logs";

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

    // ★表示上限(指示書AF 段階6)★ — `loadConsecutiveDaysMax()` は内部で既定値に倒れる
    //   設計なので通常は例外にならないが、★「設定が読めない」だけで一覧を 500 にしない★。
    //   ★型注釈 `: number` が要る★(`CONSECUTIVE_DAYS_DISPLAY` は `as const` でリテラル型)
    let maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax;
    try {
      maxDays = await loadConsecutiveDaysMax();
    } catch {
      // 既定値のまま続行する
    }

    let rows: CleanLogItem[];
    try {
      // ★★施設名の照合はアプリ側で施設IDに解決してから SQL に渡す★★(指示書AC C-1 / D-6)—
      //   ★記号(中点等)を無視する照合は LIKE では書けない★(照合順序でも無理。実測済み)。
      //   ★★戻り値の `null` と `[]` は別物★★: `null` = 施設名の条件なし(全施設)/
      //   `[]` = ★照合したが1件も当たらなかった(=0件)★。
      //   ★★分類(`group_id` / `customer_id`)も**同じ形**(施設IDの配列)に解決する★★
      //   (指示書AK 段階4)。★2本の解決結果は `intersectFacilityIds()` で AND に畳む★ —
      //   ★★どちらか片方を捨てないこと★★(`?? `で書くと、施設名と屋号を同時に
      //   指定したときに片方の条件が静かに消えます)。
      //   ★積集合が空になったら `[]`★(★`null` にしない★。`null` は「絞らない」の意味なので
      //   ★0件ヒットが全件表示に化けます★。罠1 / 落とし穴2)。
      const [nameIds, masterIds] = await Promise.all([
        resolveFacilityIdsByName(parsed.query.facilityName),
        resolveFacilityIdsByMasters(parsed.query),
      ]);
      const facilityIds = intersectFacilityIds(nameIds, masterIds);
      rows = await db.cleanLogs({
        // ★一覧なので単一ID の指定はしない★(`null` = 指定なし。指示書AC 段階3)
        id: null,
        // ★生の `searchParams` ではなく `parseCleanLogQuery()` を通った値を渡す★(§E-2)
        date_from: toDbQueryDate(parsed.query.dateFrom),
        date_to: toDbQueryDate(parsed.query.dateTo),
        facility_ids: facilityIds,
      });
    } 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);
    }

    const filtered = filterCleanLogs(rows, parsed.query);
    const { page, perPage } = parsed.query;
    const total = filtered.length;
    const start = (page - 1) * perPage;
    // ★表示上限は絞り込み → ページ切り出しの**後**にかける★(指示書AF 段階6-2)
    //   絞り込みとページングは生値のまま行う。`meta.total` も上限とは無関係。
    const items = capCleanLogs(filtered.slice(start, start + perPage), maxDays);

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