/**
* GET /clean-logs/export — 清掃詳細ログCSVエクスポート
*
* /clean-logs と同一のフィルタを受け付ける(page / per_page は無視)。
* UTF-8 BOM付き、ヘッダ行あり。
*/
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
// ★CSVの組み立ては lib/clean-logs/csv.ts★ — App Router の route.ts は
// HTTPメソッド以外の export を想定しておらず、ここに置くとテストから呼べない
// (「全列が csvCell() を通っているか」をテストで固定したいため。D-125)
import { buildCleanLogCsv } from "@/lib/clean-logs/csv";
import {
filterCleanLogs,
parseCleanLogQuery,
toDbQueryDate,
} from "@/lib/clean-logs/query";
import { MAX_CSV_ROWS } from "@/lib/config";
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 type { ApiError, CleanLogItem } from "@/lib/types";
const ROUTE = "/api/clean-logs/export";
export async function GET(request: Request) {
return withRequestId(request, async () => {
const { searchParams } = new URL(request.url);
const parsed = parseCleanLogQuery(searchParams);
if (!parsed.ok) {
return NextResponse.json(parsed.error, { status: parsed.status });
}
let rows: CleanLogItem[];
try {
// ★`/clean-logs` と**同じ引数**を渡す(指示書AC 段階2 D-6)★ —
// ★★片方だけ絞ると、画面の一覧と CSV の中身が食い違います★★。
// `page` / `per_page` はここでも無視する(CSV は全件出す)。
// ★★分類(`group_id` / `customer_id`)の解決と積集合も一覧と同じ★★(指示書AK 段階4)—
// ★ここだけ分類を無視すると、画面で屋号を選んで出した CSV に他社の行が混ざります★。
const [nameIds, masterIds] = await Promise.all([
resolveFacilityIdsByName(parsed.query.facilityName),
resolveFacilityIdsByMasters(parsed.query),
]);
const facilityIds = intersectFacilityIds(nameIds, masterIds);
rows = await db.cleanLogs({
// ★CSV も一覧と同じく単一ID の指定はしない★(指示書AC 段階3)
id: null,
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 NextResponse.json(body, { status: 500 });
}
const filtered = filterCleanLogs(rows, parsed.query);
// ★★行数の歯止め(ユーザー指示 2026-09-15 / 指示書AI 段階8)★★
//
// ★絞り込みを適用したあと、CSVを組み立てる**前**に数える★ —
// ★★組み立ててから長さで判断しないこと★★。25万行ぶんの文字列を作ってから
// 捨てるのでは、★メモリを使い切る危険が残ったまま★になる。
//
// ★★500 ではなく 422★★ — 利用者の指定が上限を超えているという話であって、
// サーバの不具合ではない(`CLAUDE.md` §9.3)。
//
// ★上限は「含む」★ — ちょうど `MAX_CSV_ROWS` 行は出せる(`>` であって `>=` ではない)。
//
// ★画面(`DetailLogScreen`)も `meta.total` を見て同じ判断をしてボタンを止めるが、
// ★★こちらを消さないこと★★ — APIを直接叩く経路が残る。
// ★画面は「親切」、サーバは「歯止め」で役割が違う★。
if (filtered.length > MAX_CSV_ROWS) {
const body: ApiError = {
error: {
code: "validation_failed",
message:
`この期間はCSVで出力できません。対象が${filtered.length}件で、` +
`上限の${MAX_CSV_ROWS}件を超えています。` +
`期間を短くするか、店舗で絞り込んでください。`,
// ★実数を入れる★ — 「あと何件減らせばよいか」が文言を読まずに分かる
details: { total: filtered.length, max: MAX_CSV_ROWS },
},
};
return NextResponse.json(body, { status: 422 });
}
const csv = buildCleanLogCsv(filtered);
// ★ここで生の searchParams を読んでよいのは、上の parseCleanLogQuery が
// `^\d{4}-\d{2}-\d{2}$`(`lib/clean-logs/query.ts` の parseDate)で既に弾いており、
// 形式違いなら 422 で return 済みだから★。だから Content-Disposition に
// `"` や改行などの制御文字は入らない。
// ★この検証を緩めるなら、ここも一緒に直すこと★(検証する場所と使う場所が
// 別の関数に分かれた暗黙の依存になっている)。
// `parsed.query` 側は Date に正規化済みで元の文字列を持たないため、生値を使う。
const from = searchParams.get("date_from") ?? "";
const to = searchParams.get("date_to") ?? "";
const range = from && to ? `_${from}_${to}` : "";
return new NextResponse(csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="clean-logs${range}.csv"`,
},
});
});
}