Newer
Older
client_dwatch / lib / ds-alerts / query.ts
/**
 * GET /ds-alerts で使うクエリ処理。
 * 仕様の正は `d-watch-api.yaml` の /ds-alerts。ソート順は D-052と同じ規則。
 */
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { PAGINATION } from "@/lib/config";
import type { DsAlert, DsAlertStatusFilter, DsAlertTypeFilter } from "@/lib/types";

const TYPE_FILTERS: readonly DsAlertTypeFilter[] = [
  "all",
  "security_off",
  "disconnected",
];
const STATUS_FILTERS: readonly DsAlertStatusFilter[] = ["open", "all", "resolved"];

export interface DsAlertQuery {
  type: DsAlertTypeFilter;
  status: DsAlertStatusFilter;
  /**
   * ホールディングス(`groups.id`)で絞り込み。★`null` = 絞らない★(指示書AK 段階2)。
   * ★`customerId` と同時に指定されたら AND★(`d-watch-api.yaml` の `GroupId`)。
   */
  groupId: number | null;
  /** 顧客(`customers.id`)で絞り込み。★`null` = 絞らない★ */
  customerId: number | null;
  page: number;
  perPage: number;
}

function toInt(value: string | null, fallback: number): number {
  if (!value) return fallback;
  const n = Number.parseInt(value, 10);
  return Number.isNaN(n) ? fallback : n;
}

/**
 * クエリパラメータをパースする。
 * 不正な値があれば `{ ok: false }` を返す(`error` / `status` をそのまま422応答に使う。
 * どのパラメータが不正かは `error.error.details` に入る)。
 * ★group_id / customer_id を読む(指示書AK 段階2。D-053 の「受け取っても無視する」は解除)★
 *   検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる。
 *   ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
 *
 * page / per_page は不正値でも失敗させず、既定値・上限へ丸める(/clean-logs と同じ挙動)。
 */
export function parseDsAlertQuery(
  searchParams: URLSearchParams
): QueryParseResult<DsAlertQuery> {
  const typeRaw = searchParams.get("type") ?? "all";
  if (!TYPE_FILTERS.includes(typeRaw as DsAlertTypeFilter)) {
    return fail(422, "validation_failed", "typeの値が不正です", {
      type: [`${TYPE_FILTERS.join(" / ")} のいずれかを指定してください`],
    });
  }

  const statusRaw = searchParams.get("status") ?? "open";
  if (!STATUS_FILTERS.includes(statusRaw as DsAlertStatusFilter)) {
    return fail(422, "validation_failed", "statusの値が不正です", {
      status: [`${STATUS_FILTERS.join(" / ")} のいずれかを指定してください`],
    });
  }

  const masters = parseMasterIdFilters(searchParams);
  if (!masters.ok) return masters;

  const page = Math.max(1, toInt(searchParams.get("page"), 1));
  const perPage = Math.min(
    PAGINATION.maxPerPage,
    Math.max(1, toInt(searchParams.get("per_page"), PAGINATION.defaultPerPage))
  );

  return {
    ok: true,
    query: {
      type: typeRaw as DsAlertTypeFilter,
      status: statusRaw as DsAlertStatusFilter,
      groupId: masters.query.groupId,
      customerId: masters.query.customerId,
      page,
      perPage,
    },
  };
}

/** alert_type のみで絞り込む */
export function filterDsAlertsByType(
  items: DsAlert[],
  type: DsAlertTypeFilter
): DsAlert[] {
  if (type === "all") return items;
  return items.filter((item) => item.alert_type === type);
}

/** open = 未解消+対応中(解消でない)/resolved = 解消のみ(指示書AX D10)。YAML定義通り */
export function filterDsAlertsByStatus(
  items: DsAlert[],
  status: DsAlertStatusFilter
): DsAlert[] {
  return items.filter((item) => {
    switch (status) {
      case "open":
        return item.resolution_status !== "resolved";
      case "resolved":
        return item.resolution_status === "resolved";
      case "all":
        return true;
    }
  });
}

/**
 * detected_at の降順。同値のときは id の昇順で安定させる(D-052と同じ規則)。
 * ★id は string(`error_id`)。辞書順(`<` / `>`)で比べる★(指示書AX D11。`"10"` が `"9"` より先)。
 *   ★`localeCompare` を使わない★(実行環境のロケールで順が変わる。`compareErrorReportKeys()` と同じ規則)
 */
export function sortDsAlerts(items: DsAlert[]): DsAlert[] {
  return [...items].sort((a, b) => {
    if (a.detected_at !== b.detected_at) {
      return a.detected_at < b.detected_at ? 1 : -1;
    }
    return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
  });
}

/** 対応状況ごとの件数(キーは `PrecheckStatus` と同じ。指示書AX D10) */
export interface DsAlertSummary {
  unresolved: number;
  in_progress: number;
  resolved: number;
}

/**
 * デバイスの現況サマリ。
 *
 * ★summary はフリートの部分集合(日付・グループ・会社)で絞る。
 *   閲覧の絞り込み(q・status・type)では絞らない。★
 *   グループ・会社マスタ導入時は、ここに絞り込みを追加する(現在は D-053 で未実装)。
 *   この区別を崩すと、F-03 で起きた「既定表示で解消済みが常に0」と同じ不具合になる(D-054)。
 */
export function summarizeDsAlerts(items: DsAlert[]): DsAlertSummary {
  const unresolved = items.filter(
    (item) => item.resolution_status === "unresolved"
  ).length;
  const in_progress = items.filter(
    (item) => item.resolution_status === "in_progress"
  ).length;
  const resolved = items.filter((item) => item.resolution_status === "resolved").length;
  return { unresolved, in_progress, resolved };
}