import { allMountainNames } from "./mountains.ts"; /** * One client property datum — the dwelling input each 风水 art reads its * slice of: xuankong needs `sitting` plus a completion date (period 运), * bazhai needs `sitting` alone (宅卦). Facing is always derived as the * opposite mountain, never stored. */ export interface PropertyDatum { /** Display label, e.g. "太古城 3座 12A". */ readonly label: string; /** 坐山 — one of the 二十四山 characters. */ readonly sitting: string; /** Completion (落成) Gregorian year; anchors the 玄空 period 运. */ readonly completionYear: number; /** Optional exact completion date for 立春-accurate period resolution. */ readonly completionMonth?: number; readonly completionDay?: number; readonly notes?: string; } export function describe(value: unknown): string { if (value === null) return "null"; if (value === undefined) return "missing"; if (typeof value === "string") return JSON.stringify(value); if (typeof value === "object") return Array.isArray(value) ? "an array" : "an object"; return String(value); } function propertyInt(value: unknown, min: number, max: number): value is number { return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max; } /** * Validate an untrusted value (parsed JSON, request body) into a * `PropertyDatum`. Throws with an actionable message on the first invalid * field; `path` prefixes messages so callers can point at e.g. `properties[2]`. */ export function validatePropertyDatum(value: unknown, path = "property"): PropertyDatum { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`${path} must be an object, got ${describe(value)}`); } const obj = value as Record; const label = obj["label"]; if (typeof label !== "string" || label.trim() === "") { throw new Error(`${path}.label must be a non-empty string, got ${describe(label)}`); } const sitting = obj["sitting"]; if (typeof sitting !== "string" || !allMountainNames().includes(sitting)) { throw new Error( `${path}.sitting must be one of the 二十四山 (${allMountainNames().join(" ")}), got ${describe(sitting)}`, ); } const completionYear = obj["completionYear"]; if (!propertyInt(completionYear, 1, 9999)) { throw new Error( `${path}.completionYear must be an integer year, got ${describe(completionYear)}`, ); } const month = obj["completionMonth"] ?? undefined; const day = obj["completionDay"] ?? undefined; if ((month === undefined) !== (day === undefined)) { throw new Error( `${path}: completionMonth and completionDay travel together (both needed for 立春-accurate period resolution)`, ); } if (month !== undefined && !propertyInt(month, 1, 12)) { throw new Error(`${path}.completionMonth must be an integer 1-12, got ${describe(month)}`); } if (day !== undefined && !propertyInt(day, 1, 31)) { throw new Error(`${path}.completionDay must be an integer 1-31, got ${describe(day)}`); } const notes = obj["notes"] ?? undefined; if (notes !== undefined && typeof notes !== "string") { throw new Error(`${path}.notes must be a string when present, got ${describe(notes)}`); } return { label: label.trim(), sitting, completionYear, ...(month !== undefined && day !== undefined ? { completionMonth: month, completionDay: day } : {}), ...(notes !== undefined && notes.trim() !== "" ? { notes: notes.trim() } : {}), }; }