Newer
Older
client_dwatch / lib / prechecks / status-update.ts
/**
 * アラート(事前チェック)の対応状況・備考を更新するときの規則(純粋関数のみ。指示書AT §3-4 / §3-6)。
 *
 * ★期待値表は `lib/prechecks/__tests__/status-update.test.ts`★(指示書AT §3-4 の #1〜#12 と同じ番号)。
 *
 * ★★パッチ(DB に書く列)を作るのは `planPrecheckStatusUpdate()` だけ★★ —
 *   リクエスト本文のキーが DB に届く経路を作らない(`parsePrecheckStatusUpdateBody()` は
 *   `status` / `note` 以外のキーを捨てる)。
 *
 * 不変条件(この規則で保たれる形。`d-watch-api.yaml` の「対応開始日時(対応中以降で設定)」に合わせた):
 *   未解消 → `responded_at`・`resolved_at` とも null / 対応中 → `responded_at` のみ / 解消 → 両方あり
 *
 * ★`now` は引数で受け取る★(関数の中で `new Date()` しない)。★`toDbJst()` はここでは通さない★ —
 *   DB に書く直前に1回だけ通す(`lib/db/robot-error-write.ts`)。
 */
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config";
import type {
  PrecheckStatus,
  PrecheckStatusPatch,
  PrecheckStatusUpdateRequest,
} from "@/lib/types";

const STATUSES: readonly PrecheckStatus[] = ["unresolved", "in_progress", "resolved"];

/**
 * 更新前の行(API の値)。日時は ★「真の瞬間」の `Date`★(`PrecheckItem` の ISO 文字列を
 * `new Date()` に通したもの)か null。
 */
export interface PrecheckStatusCurrent {
  resolution_status: PrecheckStatus;
  responded_at: Date | null;
  resolved_at: Date | null;
  note: string | null;
}

/**
 * 更新前の値と要求から「変える列だけ」のパッチを作る(キーが無い列は書かない)。
 *
 * 日時の規則(要求する状態の不変条件に合わせる):
 *   - ★足りない日時は `now` で埋める★(未解消→対応中の `responded_at`、→解消の `resolved_at` 等)
 *   - ★既にある日時は上書きしない★(#4・#10・#11。解消済みを再度「解消」にしても `resolved_at` は動かない)
 *   - ★状態を変える要求では、要求する状態で null であるべき日時を消す★(「戻すと消す」#7・#8・#9)
 *   - ★同じ状態の要求では消さない★(埋めるだけ。#12)— 画面は備考だけ変えるときも現在の状態を送るので、
 *     備考の編集で先方が入れた日時を消さないため(表に無い行。テストの「表に無い行」)
 *
 * 備考の規則(§3-6): `note` が無ければ変えない / 空文字・空白だけは null(削除)/
 *   それ以外は前後の空白を除く / トリム後が現在値と同じなら書かない。
 */
export function planPrecheckStatusUpdate(
  current: PrecheckStatusCurrent,
  request: PrecheckStatusUpdateRequest,
  now: Date
): PrecheckStatusPatch {
  const patch: PrecheckStatusPatch = {};
  const target = request.status;
  const statusChanges = target !== current.resolution_status;

  if (statusChanges) patch.status = target;

  // 要求する状態で「値があるべきか」
  const needsResponded = target === "in_progress" || target === "resolved";
  const needsResolved = target === "resolved";

  if (needsResponded) {
    if (current.responded_at === null) patch.responded_at = now;
  } else if (statusChanges && current.responded_at !== null) {
    patch.responded_at = null;
  }

  if (needsResolved) {
    if (current.resolved_at === null) patch.resolved_at = now;
  } else if (statusChanges && current.resolved_at !== null) {
    patch.resolved_at = null;
  }

  if (request.note !== undefined) {
    const trimmed = request.note.trim();
    const next = trimmed === "" ? null : trimmed;
    if (next !== current.note) patch.note = next;
  }

  return patch;
}

/** パッチが空(何も書かない)か */
export function isEmptyPatch(patch: PrecheckStatusPatch): boolean {
  return Object.keys(patch).length === 0;
}

/**
 * `PATCH /prechecks/{id}/status` の本文を検証する(JSON として読めた後の値を受ける)。
 *
 * - オブジェクトでなければ 422
 * - `status` が enum 外(無い・型違いを含む)なら 422
 * - `note` があって文字列でなければ(null も)422 / ★トリム前で★ `PRECHECK_NOTE_MAX_LENGTH` を超えれば 422
 * - ★それ以外のキーは捨てる★(`updated_at` / `error_type` / 旧名 `memo` など)
 * ★エラーに入力値をエコーしない★(`lib/api/path-params.ts` と同じ)。
 */
export function parsePrecheckStatusUpdateBody(
  body: unknown
): QueryParseResult<PrecheckStatusUpdateRequest> {
  if (body === null || typeof body !== "object" || Array.isArray(body)) {
    return fail(422, "validation_failed", "本文の形式が不正です");
  }
  const record = body as Record<string, unknown>;

  const status = record.status;
  if (typeof status !== "string" || !STATUSES.includes(status as PrecheckStatus)) {
    return fail(422, "validation_failed", "statusの値が不正です", {
      status: [`${STATUSES.join(" / ")} のいずれかを指定してください`],
    });
  }

  if (!("note" in record) || record.note === undefined) {
    return { ok: true, query: { status: status as PrecheckStatus } };
  }

  const note = record.note;
  if (typeof note !== "string") {
    return fail(422, "validation_failed", "noteは文字列で指定してください");
  }
  // 文字数はトリム前の入力そのもので数える(`PATCH /ds-devices/{serial_number}/note` と同じ理由)
  if (note.length > PRECHECK_NOTE_MAX_LENGTH) {
    return fail(422, "validation_failed", "noteが長すぎます", {
      note: [`${PRECHECK_NOTE_MAX_LENGTH}文字以内で入力してください`],
    });
  }
  return { ok: true, query: { status: status as PrecheckStatus, note } };
}