Newer
Older
client_dwatch / lib / db / error-list-source.ts
/**
 * エラー内容の選択肢(`error_lists`)の読み取りと、エラー行(`robot_error_reports.error_list_id`)の名前解決(指示書BD §5-3。D-184)。
 *
 * ★2つの用途で規則が違う★(指示書BD §3-1 / §3-2):
 *   - 選択肢(`GET /error-lists`)= ★有効な行だけ★(`inactive = 0` かつ `deleted_at IS NULL`)・★id の昇順★
 *     → `activeErrorListItems()`(★これが正★。Prisma 版の `ACTIVE_ERROR_LIST_WHERE` / `orderBy` は写し)
 *   - 名前解決(`ErrorReportItem.error_list`)= ★絞らない★(無効化・削除済みの行でも解決する。施設名と同じ。罠7)
 *     → `errorListIndex()` / `resolveErrorList()`。★`loadErrorListIndexByIds()` に `inactive` / `deleted_at` の条件を足さないこと★
 *
 * ★N+1 にしない★ — 一覧・1件とも、行の `error_list_id`(非 null)を `errorListIdsOf()` で集めて
 *   ★`errorList.findMany({ where: { id: { in } } })` を1回★だけ撃つ(id が0件なら撃たない)。
 *   ★`in` の要素数は `error_lists` の行数(決定稿 131行)を超えない★(同じ id は1つに畳む)ので分割しない。
 *
 * ★文字列は DB の値のまま★(`t()` に通さない・変換しない。D-077 と同じ。指示書BD §2)。
 * ★BigInt → number は `Number()`★(`facility_id` 等と同じ。`lib/db/error-report-mapping.ts`)。
 * ★Map のキーは `bigint.toString()`★(`bigint` は Map のキーにならない。予定の引き当てと同じ。指示書AW 4-9)。
 *
 * テストは `lib/db/__tests__/error-list-source.test.ts`。
 */
import { ERROR_LIST_NOTE_REQUIRED_IDS, ERROR_LIST_UNSELECTABLE_ERROR_TYPES } from "@/lib/config";
import type { Prisma } from "@/lib/generated/prisma/client";
import type { ErrorListItem } from "@/lib/types";

/**
 * `error_lists` から読む列(選択肢・名前解決の共通)。
 * ★`inactive` / `deleted_at` は `activeErrorListItems()`(選択肢の絞り込みの正)が見る★。名前解決では見ない。
 * ★`created_at` / `updated_at` は読まない★(API に出さない)。
 */
export const ERROR_LIST_SELECT = {
  id: true,
  error_category_main_en: true,
  error_category_main_ja: true,
  error_category_sub_en: true,
  error_category_sub_ja: true,
  error_detail_en: true,
  error_detail_ja: true,
  inactive: true,
  deleted_at: true,
} as const satisfies Prisma.ErrorListSelect;

/** `ERROR_LIST_SELECT` の結果の形(fixtures の `FixtureErrorListRow` もこれを満たす) */
export interface ErrorListRowSource {
  id: bigint;
  error_category_main_en: string;
  error_category_main_ja: string;
  error_category_sub_en: string;
  error_category_sub_ja: string;
  error_detail_en: string;
  error_detail_ja: string;
  inactive: number;
  deleted_at: Date | null;
}

/**
 * 選択肢の `where`(★SQL 側の写し。正は `isActiveErrorListRow()`★)。
 * ★名前解決(`loadErrorListIndexByIds()`)には使わない★。
 */
export const ACTIVE_ERROR_LIST_WHERE = {
  inactive: 0,
  deleted_at: null,
} as const satisfies Prisma.ErrorListWhereInput;

/** 選択肢に出す行か(`inactive === 0` かつ `deleted_at === null`。★`inactive` は 0 以外をすべて無効とみなす★) */
export function isActiveErrorListRow(row: Pick<ErrorListRowSource, "inactive" | "deleted_at">): boolean {
  return row.inactive === 0 && row.deleted_at === null;
}

/** 行 → `ErrorListItem`(★文字列は変換しない★。`note_required` は `ERROR_LIST_NOTE_REQUIRED_IDS` から) */
export function errorListItemFromRow(row: ErrorListRowSource): ErrorListItem {
  const id = Number(row.id);
  return {
    id,
    error_category_main_en: row.error_category_main_en,
    error_category_main_ja: row.error_category_main_ja,
    error_category_sub_en: row.error_category_sub_en,
    error_category_sub_ja: row.error_category_sub_ja,
    error_detail_en: row.error_detail_en,
    error_detail_ja: row.error_detail_ja,
    note_required: ERROR_LIST_NOTE_REQUIRED_IDS.includes(id),
  };
}

/**
 * 選択肢(`GET /error-lists`): ★有効な行だけ・id の昇順★(fixtures 版・Prisma 版で共有。★これが正★)。
 * ★入力の配列を並べ替えない★(コピーしてから並べる)。
 */
export function activeErrorListItems(rows: readonly ErrorListRowSource[]): ErrorListItem[] {
  return rows
    .filter(isActiveErrorListRow)
    .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
    .map(errorListItemFromRow);
}

/** 名前解決の索引(キーは `error_lists.id` の `toString()`) */
export type ErrorListIndex = ReadonlyMap<string, ErrorListItem>;

/** 空の索引(`error_list_id` が全部 null のとき。★クエリを撃たない★) */
export const EMPTY_ERROR_LIST_INDEX: ErrorListIndex = new Map();

/** ★絞らずに★索引を作る(無効化・削除済みの行も入れる。名前解決は行の有無だけを見る) */
export function errorListIndex(rows: readonly ErrorListRowSource[]): ErrorListIndex {
  return new Map(rows.map((row) => [row.id.toString(), errorListItemFromRow(row)]));
}

/** エラー行の `error_list_id` のうち非 null のものを、重複なく集める(`in` に渡す。並びは初出順) */
export function errorListIdsOf(rows: readonly { error_list_id: bigint | null }[]): bigint[] {
  const seen = new Map<string, bigint>();
  for (const row of rows) {
    if (row.error_list_id !== null) seen.set(row.error_list_id.toString(), row.error_list_id);
  }
  return [...seen.values()];
}

/**
 * `error_list_id` → `ErrorListItem | null`。
 * ★null(未選択)→ null★/★索引に無い id(`error_lists` に無い)→ null★(画面は後者を「不明(id)」と出す。`error_list_id` は値を保つ)。
 */
export function resolveErrorList(errorListId: bigint | null, index: ErrorListIndex): ErrorListItem | null {
  if (errorListId === null) return null;
  return index.get(errorListId.toString()) ?? null;
}

/**
 * この行でエラー内容を選べるか(`ErrorReportItem.error_list_selectable`。指示書BD §3-2)。
 * ★`ERROR_LIST_UNSELECTABLE_ERROR_TYPES` との完全一致(`===`)★ — 大小文字・前後の空白を緩めない
 * (`D_SEC_ALERT_ERROR_TYPES` と同じ。緩めると、選べるはずの行が選べなくなる/その逆が静かに起きる)。
 * ★`PATCH /errors/{error_id}/status` の検証(指示書BD §3-3 手順2)も、行の `error_list_selectable` を通してこの1本を使う★。
 */
export function isErrorListSelectable(errorType: string): boolean {
  return !ERROR_LIST_UNSELECTABLE_ERROR_TYPES.includes(errorType);
}

/**
 * この関数が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★。`facility-name-source.ts` と同じ作法)。
 */
export interface ErrorListSourcePrisma {
  errorList: {
    findMany(args: {
      where: { id: { in: bigint[] } };
      select: typeof ERROR_LIST_SELECT;
    }): Promise<ErrorListRowSource[]>;
  };
}

/**
 * 名前解決の索引を Prisma から作る(一覧・1件で共有)。
 * ★`where` は `id IN` だけ★(`inactive` / `deleted_at` で絞らない。罠7と同じ。`lib/db/__tests__/error-list-source.test.ts` が固定)。
 * ★id が0件ならクエリを撃たない★。
 */
export async function loadErrorListIndexByIds(
  prisma: ErrorListSourcePrisma,
  ids: readonly bigint[]
): Promise<ErrorListIndex> {
  if (ids.length === 0) return EMPTY_ERROR_LIST_INDEX;
  const rows = await prisma.errorList.findMany({
    where: { id: { in: [...ids] } },
    select: ERROR_LIST_SELECT,
  });
  return errorListIndex(rows);
}